Переглянути джерело

第二次提及,修改了运单管理

lwx 7 місяців тому
батько
коміт
c2b60c8703
100 змінених файлів з 5281 додано та 27 видалено
  1. 18 0
      .editorconfig
  2. 33 0
      .eslintrc
  3. 46 0
      .gitattributes
  4. 3 0
      .husky/post-checkout
  5. 3 0
      .husky/post-commit
  6. 3 0
      .husky/post-merge
  7. 3 0
      .husky/pre-push
  8. 339 0
      LICENSE
  9. 20 0
      mock/_mockProdServer.ts
  10. 27 0
      mock/sample/sample.ts
  11. 8 0
      plop-templates/api.hbs
  12. 42 0
      plop-templates/view_form.hbs
  13. 120 0
      plop-templates/view_list.hbs
  14. 129 0
      plop-templates/view_page.hbs
  15. BIN
      public/favicon.png
  16. 471 0
      public/tinymce/langs/zh_CN.js
  17. 425 0
      public/tinymce/langs/zh_TW.js
  18. 7 0
      public/tinymce/skins/content/dark/content.min.css
  19. 7 0
      public/tinymce/skins/content/default/content.min.css
  20. 7 0
      public/tinymce/skins/content/document/content.min.css
  21. 7 0
      public/tinymce/skins/content/writer/content.min.css
  22. 6 0
      public/tinymce/skins/ui/oxide-dark/content.inline.min.css
  23. 6 0
      public/tinymce/skins/ui/oxide-dark/content.min.css
  24. 7 0
      public/tinymce/skins/ui/oxide-dark/content.mobile.min.css
  25. BIN
      public/tinymce/skins/ui/oxide-dark/fonts/tinymce-mobile.woff
  26. 6 0
      public/tinymce/skins/ui/oxide-dark/skin.min.css
  27. 6 0
      public/tinymce/skins/ui/oxide-dark/skin.mobile.min.css
  28. 7 0
      public/tinymce/skins/ui/oxide-dark/skin.shadowdom.min.css
  29. 6 0
      public/tinymce/skins/ui/oxide/content.inline.min.css
  30. 6 0
      public/tinymce/skins/ui/oxide/content.min.css
  31. 7 0
      public/tinymce/skins/ui/oxide/content.mobile.min.css
  32. BIN
      public/tinymce/skins/ui/oxide/fonts/tinymce-mobile.woff
  33. 6 0
      public/tinymce/skins/ui/oxide/skin.min.css
  34. 6 0
      public/tinymce/skins/ui/oxide/skin.mobile.min.css
  35. 7 0
      public/tinymce/skins/ui/oxide/skin.shadowdom.min.css
  36. 11 27
      src/App.vue
  37. 66 0
      src/api/config.ts
  38. 61 0
      src/api/content.ts
  39. 33 0
      src/api/login.ts
  40. 11 0
      src/api/statistics.ts
  41. 45 0
      src/api/system.ts
  42. 44 0
      src/api/user.ts
  43. BIN
      src/assets/404_images/404.png
  44. BIN
      src/assets/404_images/404_cloud.png
  45. BIN
      src/assets/bg.png
  46. BIN
      src/assets/home-footer.png
  47. BIN
      src/assets/home-top.png
  48. BIN
      src/assets/iApp.png
  49. BIN
      src/assets/icon.png
  50. BIN
      src/assets/layout-bg.png
  51. BIN
      src/assets/layout-top.png
  52. 1 0
      src/assets/svg/add_item.svg
  53. 1 0
      src/assets/svg/content.svg
  54. 1 0
      src/assets/svg/icon_email.svg
  55. 1 0
      src/assets/svg/icon_mob.svg
  56. 1 0
      src/assets/svg/que_del.svg
  57. 1 0
      src/assets/svg/que_edit.svg
  58. 1 0
      src/assets/svg/que_move.svg
  59. 1 0
      src/assets/svg/remove_item.svg
  60. 1 0
      src/assets/svg/system.svg
  61. BIN
      src/assets/szgzh.png
  62. 261 0
      src/components/DialogForm.vue
  63. 20 0
      src/components/LabelTip.vue
  64. 19 0
      src/components/ListMove.vue
  65. 57 0
      src/components/QueryForm/QueryForm.vue
  66. 12 0
      src/components/QueryForm/QueryInput.vue
  67. 56 0
      src/components/QueryForm/QueryItem.vue
  68. 2 0
      src/components/QueryForm/index.ts
  69. 45 0
      src/components/TableList/ColumnList.vue
  70. 31 0
      src/components/TableList/ColumnSetting.vue
  71. 2 0
      src/components/TableList/index.ts
  72. 55 0
      src/components/TableList/useColumns.ts
  73. 377 0
      src/components/Tinymce/Tinymce.vue
  74. 2 0
      src/components/Tinymce/index.ts
  75. 139 0
      src/components/Tinymce/utils.ts
  76. 167 0
      src/components/TuiEditor/TuiEditor.vue
  77. 1 0
      src/components/TuiEditor/index.ts
  78. 76 0
      src/components/TuiEditor/utils.ts
  79. 433 0
      src/components/bpmnjs/context-pad/FlowableContextPadProvider.js
  80. 6 0
      src/components/bpmnjs/context-pad/index.js
  81. 14 0
      src/components/bpmnjs/customTranslate/customTranslate.ts
  82. 15 0
      src/components/bpmnjs/customTranslate/defaultBpmnXml.ts
  83. 349 0
      src/components/bpmnjs/customTranslate/translations.ts
  84. 32 0
      src/components/bpmnjs/descriptors/flowable.json
  85. 160 0
      src/components/bpmnjs/palette/FlowablePaletteProvider.js
  86. 7 0
      src/components/bpmnjs/palette/index.js
  87. 89 0
      src/components/bpmnjs/properties-panel/FlowablePropertiesPannel.vue
  88. 73 0
      src/components/bpmnjs/provider/bpmn/BpmnPropertiesProvider.js
  89. 6 0
      src/components/bpmnjs/provider/bpmn/index.js
  90. 155 0
      src/components/bpmnjs/provider/bpmn/properties/DocumentationProps.js
  91. 79 0
      src/components/bpmnjs/provider/bpmn/properties/ExecutableProps.js
  92. 55 0
      src/components/bpmnjs/provider/bpmn/properties/IdProps.js
  93. 124 0
      src/components/bpmnjs/provider/bpmn/properties/NameProps.js
  94. 107 0
      src/components/bpmnjs/provider/bpmn/properties/ProcessProps.js
  95. 5 0
      src/components/bpmnjs/provider/bpmn/properties/index.js
  96. 49 0
      src/components/bpmnjs/provider/bpmn/utils/ValidationUtil.js
  97. 80 0
      src/components/bpmnjs/provider/flowable/FlowablePropertiesProvider.js
  98. 6 0
      src/components/bpmnjs/provider/flowable/index.js
  99. 40 0
      src/components/bpmnjs/provider/flowable/properties/CandidateGroupsProps.js
  100. 1 0
      src/components/bpmnjs/provider/flowable/properties/index.js

+ 18 - 0
.editorconfig

@@ -0,0 +1,18 @@
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+end_of_line = lf
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.{js,jsx,ts,tsx,vue,hbs}]
+max_line_length = 180
+
+[*.md]
+trim_trailing_whitespace = false
+
+[Makefile]
+indent_style = tab

+ 33 - 0
.eslintrc

@@ -0,0 +1,33 @@
+{
+  "env": {
+    "browser": true,
+    "es2021": true,
+    "node": true
+  },
+  "extends": [
+    "eslint:recommended",
+    "plugin:vue/vue3-recommended",
+    "plugin:@typescript-eslint/recommended",
+    "plugin:prettier/recommended"
+  ],
+  "parser": "vue-eslint-parser",
+  "parserOptions": {
+    // "extraFileExtensions": [".vue"],
+    // "project": ["./tsconfig.json"],
+    "ecmaVersion": "latest",
+    "sourceType": "module",
+    "parser": "@typescript-eslint/parser"
+  },
+  "plugins": ["vue", "@typescript-eslint"],
+  "rules": {
+    // "no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["state"] }],
+    // "import/prefer-default-export": "off",
+
+    // "@typescript-eslint/explicit-module-boundary-types": "off",
+    // // 避免使用Q_作为查询参数时报错
+    // "@typescript-eslint/camelcase": "off",
+
+    // 允许使用 any 类型
+    "@typescript-eslint/no-explicit-any": "off"
+  }
+}

+ 46 - 0
.gitattributes

@@ -0,0 +1,46 @@
+###############################
+# Git Line Endings            #
+###############################
+
+# Set default behaviour to automatically normalize line endings.
+# * text=auto
+# 文本文件全部使用lf换行,eslint prettier等工具保持一致。
+* text=auto eol=lf
+
+# Force batch scripts to always use CRLF line endings so that if a repo is accessed
+# in Windows via a file share from Linux, the scripts will work.
+*.{cmd,[cC][mM][dD]} text eol=crlf
+*.{bat,[bB][aA][tT]} text eol=crlf
+
+# Force bash scripts to always use LF line endings so that if a repo is accessed
+# in Unix via a file share from Windows, the scripts will work.
+*.sh text eol=lf
+
+###############################
+# Git Large File System (LFS) #
+###############################
+
+# # Archives
+# *.7z filter=lfs diff=lfs merge=lfs -text
+# *.br filter=lfs diff=lfs merge=lfs -text
+# *.gz filter=lfs diff=lfs merge=lfs -text
+# *.tar filter=lfs diff=lfs merge=lfs -text
+# *.zip filter=lfs diff=lfs merge=lfs -text
+
+# # Documents
+# *.pdf filter=lfs diff=lfs merge=lfs -text
+
+# # Images
+# *.gif filter=lfs diff=lfs merge=lfs -text
+# *.ico filter=lfs diff=lfs merge=lfs -text
+# *.jpg filter=lfs diff=lfs merge=lfs -text
+# *.pdf filter=lfs diff=lfs merge=lfs -text
+# *.png filter=lfs diff=lfs merge=lfs -text
+# *.psd filter=lfs diff=lfs merge=lfs -text
+# *.webp filter=lfs diff=lfs merge=lfs -text
+
+# # Fonts
+# *.woff2 filter=lfs diff=lfs merge=lfs -text
+
+# # Other
+# *.exe filter=lfs diff=lfs merge=lfs -text

+ 3 - 0
.husky/post-checkout

@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-checkout.\n"; exit 2; }
+git lfs post-checkout "$@"

+ 3 - 0
.husky/post-commit

@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-commit.\n"; exit 2; }
+git lfs post-commit "$@"

+ 3 - 0
.husky/post-merge

@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-merge.\n"; exit 2; }
+git lfs post-merge "$@"

+ 3 - 0
.husky/pre-push

@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/pre-push.\n"; exit 2; }
+git lfs pre-push "$@"

+ 339 - 0
LICENSE

@@ -0,0 +1,339 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                    GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+                            NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, write to the Free Software Foundation, Inc.,
+    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Ty Coon>, 1 April 1989
+  Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.

+ 20 - 0
mock/_mockProdServer.ts

@@ -0,0 +1,20 @@
+import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer';
+
+// Import your mock .ts files one by one
+// If you use vite.mock.config.ts, just import the file directly
+// You can use the import.meta.glob function to import all
+const modules: Record<string, any> = import.meta.glob('./**/*.ts', {
+  import: 'default',
+  eager: true,
+});
+
+const mockModules = Object.keys(modules).reduce((pre, key) => {
+  if (!key.includes('/_')) {
+    pre.push(...modules[key]);
+  }
+  return pre;
+}, [] as any[]);
+
+export function setupProdMockServer() {
+  createProdMockServer(mockModules);
+}

+ 27 - 0
mock/sample/sample.ts

@@ -0,0 +1,27 @@
+import { MockMethod } from 'vite-plugin-mock';
+export default [
+  {
+    url: '/sample',
+    method: 'get',
+    // response: ({ query, body }: any) => {
+    response: () => {
+      return {
+        code: 0,
+        message: 'ok',
+        data: {
+          total: 2,
+          list: [
+            {
+              id: 100,
+              title: 'Mock测试数据100',
+            },
+            {
+              id: 101,
+              title: 'Mock测试数据101',
+            },
+          ],
+        },
+      };
+    },
+  },
+] as MockMethod[];

+ 8 - 0
plop-templates/api.hbs

@@ -0,0 +1,8 @@
+export const query{{pascalCase name}}{{pascalCase type}} = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/{{kebabCase name}}', { params })).data;
+export const query{{pascalCase name}} = async (id: number): Promise<any> => (await axios.get(`/backend/core/{{kebabCase name}}/${id}`)).data;
+export const create{{pascalCase name}} = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/{{kebabCase name}}', data)).data;
+export const update{{pascalCase name}} = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/{{kebabCase name}}?_method=put', data)).data;
+{{#if isList}}
+export const update{{pascalCase name}}Order = async (data: number[]): Promise<any> => (await axios.post('/backend/core/{{kebabCase name}}/order?_method=put', data)).data;
+{{/if}}
+export const delete{{pascalCase name}} = async (data: number[]): Promise<any> => (await axios.post('/backend/core/{{kebabCase name}}?_method=delete', data)).data;

+ 42 - 0
plop-templates/view_form.hbs

@@ -0,0 +1,42 @@
+<script lang="ts">
+export default { name: '{{pascalCase name}}Form' };
+</script>
+
+<script setup lang="ts">
+import { ref, PropType } from 'vue';
+import { query{{pascalCase name}}, create{{pascalCase name}}, update{{pascalCase name}}, delete{{pascalCase name}} } from '@/api/{{kebabCase path}}';
+import DialogForm from '@/components/DialogForm.vue';
+import LabelTip from '@/components/LabelTip.vue';
+
+defineProps({ modelValue: { type: Boolean, required: true }, beanId: { type: Number, default: null }, beanIds: { type: Array as PropType<number[]>, required: true } });
+defineEmits({ 'update:modelValue': null, finished: null });
+const focus = ref();
+const values = ref<Record<string, any>>({});
+</script>
+
+<template>
+  <dialog-form
+    v-model:values="values"
+    :name="$t('menu.{{camelCase path}}.{{camelCase name}}')"
+    :query-bean="query{{pascalCase name}}"
+    :create-bean="create{{pascalCase name}}"
+    :update-bean="update{{pascalCase name}}"
+    :delete-bean="delete{{pascalCase name}}"
+    :bean-id="beanId"
+    :bean-ids="beanIds"
+    :focus="focus"
+    :init-values="(): any => ({})"
+    :to-values="(bean) => ({ ...bean })"
+    :model-value="modelValue"
+    perms="{{camelCase name}}"
+    @update:model-value="(event) => $emit('update:modelValue', event)"
+    @finished="() => $emit('finished')"
+  >
+    <template #default="{ bean }">
+      <el-form-item prop="name" :rules="{ required: true, message: () => $t('v.required') }">
+        <template #label><label-tip message="{{camelCase name}}.name" /></template>
+        <el-input ref="focus" v-model="values.name" maxlength="50"></el-input>
+      </el-form-item>
+    </template>
+  </dialog-form>
+</template>

+ 120 - 0
plop-templates/view_list.hbs

@@ -0,0 +1,120 @@
+<script lang="ts">
+export default { name: '{{pascalCase name}}List' };
+</script>
+
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue';
+import { ElMessage } from 'element-plus';
+import { Plus, Delete } from '@element-plus/icons-vue';
+import { useI18n } from 'vue-i18n';
+import { perm } from '@/store/useCurrentUser';
+import { moveList, toParams, resetParams } from '@/utils/common';
+import { delete{{pascalCase name}}, query{{pascalCase name}}List, update{{pascalCase name}}Order } from '@/api/{{kebabCase path}}';
+import { ColumnList, ColumnSetting } from '@/components/TableList';
+import { QueryForm, QueryItem } from '@/components/QueryForm';
+import ListMove from '@/components/ListMove.vue';
+import {{pascalCase name}}Form from './{{pascalCase name}}Form.vue';
+
+const { t } = useI18n();
+const params = ref<Record<string, any>>({});
+const sort = ref();
+const table = ref();
+const data = ref<any[]>([]);
+const selection = ref<any[]>([]);
+const loading = ref<boolean>(false);
+const formVisible = ref<boolean>(false);
+const beanId = ref<number>();
+const beanIds = computed(() => data.value.map((row) => row.id));
+const filtered = ref<boolean>(false);
+const fetchData = async () => {
+  loading.value = true;
+  try {
+    data.value = await query{{pascalCase name}}List({ ...toParams(params.value), Q_OrderBy: sort.value });
+    filtered.value = Object.values(params.value).filter((v) => v !== undefined && v !== '').length > 0 || sort.value !== undefined;
+  } finally {
+    loading.value = false;
+  }
+};
+onMounted(fetchData);
+
+const handleSort = ({ column, prop, order }: { column: any; prop: string; order: string }) => {
+  if (prop) {
+    sort.value = (column.sortBy ?? prop) + (order === 'descending' ? '_desc' : '');
+  } else {
+    sort.value = undefined;
+  }
+  fetchData();
+};
+const handleSearch = () => fetchData();
+const handleReset = () => {
+  table.value.clearSort();
+  resetParams(params.value);
+  sort.value = undefined;
+  fetchData();
+};
+
+const handleAdd = () => {
+  beanId.value = undefined;
+  formVisible.value = true;
+};
+const handleEdit = (id: number) => {
+  beanId.value = id;
+  formVisible.value = true;
+};
+const handleDelete = async (ids: number[]) => {
+  await delete{{pascalCase name}}(ids);
+  fetchData();
+  ElMessage.success(t('success'));
+};
+const move = async (selected: any[], type: 'top' | 'up' | 'down' | 'bottom') => {
+  const list = moveList(selected, data.value, type);
+  await update{{pascalCase name}}Order(list.map((item) => item.id));
+};
+</script>
+
+<template>
+  <div>
+    <div class="mb-3">
+      <query-form :params="params" @search="handleSearch" @reset="() => handleReset()">
+        <query-item :label="$t('{{camelCase name}}.name')" name="Q_Contains_name"></query-item>
+      </query-form>
+    </div>
+    <div class="space-x-2">
+      <el-button type="primary" :disabled="perm('{{camelCase name}}:create')" :icon="Plus" @click="() => handleAdd()">\{{ $t('add') }}</el-button>
+      <el-popconfirm :title="$t('confirmDelete')" @confirm="() => handleDelete(selection.map((row) => row.id))">
+        <template #reference>
+          <el-button :disabled="selection.length <= 0 || perm('{{camelCase name}}:delete')" :icon="Delete">\{{ $t('delete') }}</el-button>
+        </template>
+      </el-popconfirm>
+      <list-move :disabled="selection.length <= 0 || filtered || perm('org:update')" @move="(type) => move(selection, type)" />
+      <column-setting name="{{camelCase name}}" />
+    </div>
+    <div class="app-block mt-3">
+      <el-table
+        ref="table"
+        v-loading="loading"
+        :data="data"
+        @selection-change="(rows) => (selection = rows)"
+        @row-dblclick="(row) => handleEdit(row.id)"
+        @sort-change="handleSort"
+      >
+        <column-list name="{{camelCase name}}">
+          <el-table-column type="selection" width="45"></el-table-column>
+          <el-table-column property="id" label="ID" width="64" sortable="custom"></el-table-column>
+          <el-table-column property="name" :label="$t('{{camelCase name}}.name')" sortable="custom" show-overflow-tooltip></el-table-column>
+          <el-table-column :label="$t('table.action')">
+            <template #default="{row}">
+              <el-button type="primary" :disabled="perm('{{camelCase name}}:update')" @click="() => handleEdit(row.id)" size="small" link>\{{ $t('edit') }}</el-button>
+              <el-popconfirm :title="$t('confirmDelete')" @confirm="() => handleDelete([row.id])">
+                <template #reference>
+                  <el-button type="primary" :disabled="perm('{{camelCase name}}:delete')" size="small" link>\{{ $t('delete') }}</el-button>
+                </template>
+              </el-popconfirm>
+            </template>
+          </el-table-column>
+        </column-list>
+      </el-table>
+    </div>
+    <{{kebabCase name}}-form v-model="formVisible" :bean-id="beanId" :bean-ids="beanIds" @finished="() => fetchData()" />
+  </div>
+</template>

+ 129 - 0
plop-templates/view_page.hbs

@@ -0,0 +1,129 @@
+<script lang="ts">
+export default { name: '{{pascalCase name}}List' };
+</script>
+
+<script setup lang="ts">
+import { computed, onMounted, ref } from 'vue';
+import { ElMessage } from 'element-plus';
+import { Plus, Delete } from '@element-plus/icons-vue';
+import { useI18n } from 'vue-i18n';
+import { perm } from '@/store/useCurrentUser';
+import { pageSizes, pageLayout, toParams, resetParams } from '@/utils/common';
+import { delete{{pascalCase name}}, query{{pascalCase name}}Page } from '@/api/{{kebabCase path}}';
+import { ColumnList, ColumnSetting } from '@/components/TableList';
+import { QueryForm, QueryItem } from '@/components/QueryForm';
+import {{pascalCase name}}Form from './{{pascalCase name}}Form.vue';
+
+const { t } = useI18n();
+const params = ref<Record<string, any>>({});
+const sort = ref();
+const currentPage = ref<number>(1);
+const pageSize = ref<number>(10);
+const total = ref<number>(0);
+const table = ref();
+const data = ref<any[]>([]);
+const selection = ref<any[]>([]);
+const loading = ref<boolean>(false);
+const formVisible = ref<boolean>(false);
+const beanId = ref<number>();
+const beanIds = computed(() => data.value.map((row) => row.id));
+const fetchData = async () => {
+  loading.value = true;
+  try {
+    const { content, totalElements } = await query{{pascalCase name}}Page({ ...toParams(params.value), Q_OrderBy: sort.value, page: currentPage.value, pageSize: pageSize.value });
+    data.value = content;
+    total.value = totalElements;
+  } finally {
+    loading.value = false;
+  }
+};
+onMounted(fetchData);
+
+const handleSort = ({ column, prop, order }: { column: any; prop: string; order: string }) => {
+  if (prop) {
+    sort.value = (column.sortBy ?? prop) + (order === 'descending' ? '_desc' : '');
+  } else {
+    sort.value = undefined;
+  }
+  fetchData();
+};
+const handleSearch = () => fetchData();
+const handleReset = () => {
+  table.value.clearSort();
+  resetParams(params.value);
+  sort.value = undefined;
+  fetchData();
+};
+
+const handleAdd = () => {
+  beanId.value = undefined;
+  formVisible.value = true;
+};
+const handleEdit = (id: number) => {
+  beanId.value = id;
+  formVisible.value = true;
+};
+const handleDelete = async (ids: number[]) => {
+  await delete{{pascalCase name}}(ids);
+  fetchData();
+  ElMessage.success(t('success'));
+};
+</script>
+
+<template>
+  <div>
+    <div class="mb-3">
+      <query-form :params="params" @search="handleSearch" @reset="() => handleReset()">
+        <query-item :label="$t('{{camelCase name}}.name')" name="Q_Contains_name"></query-item>
+      </query-form>
+    </div>
+    <div>
+      <el-button type="primary" :disabled="perm('{{camelCase name}}:create')" :icon="Plus" @click="() => handleAdd()">\{{ $t('add') }}</el-button>
+      <el-popconfirm :title="$t('confirmDelete')" @confirm="() => handleDelete(selection.map((row) => row.id))">
+        <template #reference>
+          <el-button :disabled="selection.length <= 0 || perm('{{camelCase name}}:delete')" :icon="Delete">\{{ $t('delete') }}</el-button>
+        </template>
+      </el-popconfirm>
+      <column-setting name="{{camelCase name}}" class="ml-2" />
+    </div>
+    <div class="app-block mt-3">
+      <el-table
+        ref="table"
+        v-loading="loading"
+        :data="data"
+        @selection-change="(rows) => (selection = rows)"
+        @row-dblclick="(row) => handleEdit(row.id)"
+        @sort-change="handleSort"
+      >
+        <column-list name="{{camelCase name}}">
+          <el-table-column type="selection" width="45"></el-table-column>
+          <el-table-column property="id" label="ID" width="64" sortable="custom"></el-table-column>
+          <el-table-column property="name" :label="$t('{{camelCase name}}.name')" sortable="custom" show-overflow-tooltip></el-table-column>
+          <el-table-column :label="$t('table.action')">
+            <template #default="{row}">
+              <el-button type="primary" :disabled="perm('{{camelCase name}}:update')" @click="() => handleEdit(row.id)" size="small" link>\{{ $t('edit') }}</el-button>
+              <el-popconfirm :title="$t('confirmDelete')" @confirm="() => handleDelete([row.id])">
+                <template #reference>
+                  <el-button type="primary" :disabled="perm('{{camelCase name}}:delete')" size="small" link>\{{ $t('delete') }}</el-button>
+                </template>
+              </el-popconfirm>
+            </template>
+          </el-table-column>
+        </column-list>
+      </el-table>
+      <el-pagination
+        v-model:currentPage="currentPage"
+        v-model:pageSize="pageSize"
+        :total="total"
+        :page-sizes="pageSizes"
+        :layout="pageLayout"
+        class="px-3 py-2 justify-end"
+        small
+        background
+        @size-change="() => fetchData()"
+        @current-change="() => fetchData()"
+      ></el-pagination>
+    </div>
+    <{{kebabCase name}}-form v-model="formVisible" :bean-id="beanId" :bean-ids="beanIds" @finished="fetchData" />
+  </div>
+</template>

BIN
public/favicon.png


+ 471 - 0
public/tinymce/langs/zh_CN.js

@@ -0,0 +1,471 @@
+tinymce.addI18n('zh_CN', {
+  Redo: '\u91cd\u505a',
+  Undo: '\u64a4\u9500',
+  Cut: '\u526a\u5207',
+  Copy: '\u590d\u5236',
+  Paste: '\u7c98\u8d34',
+  'Select all': '\u5168\u9009',
+  'New document': '\u65b0\u6587\u4ef6',
+  Ok: '\u786e\u5b9a',
+  Cancel: '\u53d6\u6d88',
+  'Visual aids': '\u7f51\u683c\u7ebf',
+  Bold: '\u7c97\u4f53',
+  Italic: '\u659c\u4f53',
+  Underline: '\u4e0b\u5212\u7ebf',
+  Strikethrough: '\u5220\u9664\u7ebf',
+  Superscript: '\u4e0a\u6807',
+  Subscript: '\u4e0b\u6807',
+  'Clear formatting': '\u6e05\u9664\u683c\u5f0f',
+  'Align left': '\u5de6\u8fb9\u5bf9\u9f50',
+  'Align center': '\u4e2d\u95f4\u5bf9\u9f50',
+  'Align right': '\u53f3\u8fb9\u5bf9\u9f50',
+  Justify: '\u4e24\u7aef\u5bf9\u9f50',
+  'Bullet list': '\u9879\u76ee\u7b26\u53f7',
+  'Numbered list': '\u7f16\u53f7\u5217\u8868',
+  'Decrease indent': '\u51cf\u5c11\u7f29\u8fdb',
+  'Increase indent': '\u589e\u52a0\u7f29\u8fdb',
+  Close: '\u5173\u95ed',
+  Formats: '\u683c\u5f0f',
+  "Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X/C/V keyboard shortcuts instead.":
+    '\u4f60\u7684\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u6253\u5f00\u526a\u8d34\u677f\uff0c\u8bf7\u4f7f\u7528Ctrl+X/C/V\u7b49\u5feb\u6377\u952e\u3002',
+  Headers: '\u6807\u9898',
+  'Header 1': '\u6807\u98981',
+  'Header 2': '\u6807\u98982',
+  'Header 3': '\u6807\u98983',
+  'Header 4': '\u6807\u98984',
+  'Header 5': '\u6807\u98985',
+  'Header 6': '\u6807\u98986',
+  Headings: '\u6807\u9898',
+  'Heading 1': '\u6807\u98981',
+  'Heading 2': '\u6807\u98982',
+  'Heading 3': '\u6807\u98983',
+  'Heading 4': '\u6807\u98984',
+  'Heading 5': '\u6807\u98985',
+  'Heading 6': '\u6807\u98986',
+  Preformatted: '\u9884\u5148\u683c\u5f0f\u5316\u7684',
+  Div: 'Div',
+  Pre: 'Pre',
+  Code: '\u4ee3\u7801',
+  Paragraph: '\u6bb5\u843d',
+  Blockquote: '\u5f15\u6587\u533a\u5757',
+  Inline: '\u6587\u672c',
+  Blocks: '\u57fa\u5757',
+  'Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.':
+    '\u5f53\u524d\u4e3a\u7eaf\u6587\u672c\u7c98\u8d34\u6a21\u5f0f\uff0c\u518d\u6b21\u70b9\u51fb\u53ef\u4ee5\u56de\u5230\u666e\u901a\u7c98\u8d34\u6a21\u5f0f\u3002',
+  Fonts: '\u5b57\u4f53',
+  'Font Sizes': '\u5b57\u53f7',
+  Class: '\u7c7b\u578b',
+  'Browse for an image': '\u6d4f\u89c8\u56fe\u50cf',
+  OR: '\u6216',
+  'Drop an image here': '\u62d6\u653e\u4e00\u5f20\u56fe\u50cf\u81f3\u6b64',
+  Upload: '\u4e0a\u4f20',
+  Block: '\u5757',
+  Align: '\u5bf9\u9f50',
+  Default: '\u9ed8\u8ba4',
+  Circle: '\u7a7a\u5fc3\u5706',
+  Disc: '\u5b9e\u5fc3\u5706',
+  Square: '\u65b9\u5757',
+  'Lower Alpha': '\u5c0f\u5199\u82f1\u6587\u5b57\u6bcd',
+  'Lower Greek': '\u5c0f\u5199\u5e0c\u814a\u5b57\u6bcd',
+  'Lower Roman': '\u5c0f\u5199\u7f57\u9a6c\u5b57\u6bcd',
+  'Upper Alpha': '\u5927\u5199\u82f1\u6587\u5b57\u6bcd',
+  'Upper Roman': '\u5927\u5199\u7f57\u9a6c\u5b57\u6bcd',
+  'Anchor...': '\u951a\u70b9...',
+  Name: '\u540d\u79f0',
+  Id: '\u6807\u8bc6\u7b26',
+  'Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.':
+    '\u6807\u8bc6\u7b26\u5e94\u8be5\u4ee5\u5b57\u6bcd\u5f00\u5934\uff0c\u540e\u8ddf\u5b57\u6bcd\u3001\u6570\u5b57\u3001\u7834\u6298\u53f7\u3001\u70b9\u3001\u5192\u53f7\u6216\u4e0b\u5212\u7ebf\u3002',
+  'You have unsaved changes are you sure you want to navigate away?': '\u4f60\u8fd8\u6709\u6587\u6863\u5c1a\u672a\u4fdd\u5b58\uff0c\u786e\u5b9a\u8981\u79bb\u5f00\uff1f',
+  'Restore last draft': '\u6062\u590d\u4e0a\u6b21\u7684\u8349\u7a3f',
+  'Special character...': '\u7279\u6b8a\u5b57\u7b26...',
+  'Source code': '\u6e90\u4ee3\u7801',
+  'Insert/Edit code sample': '\u63d2\u5165/\u7f16\u8f91\u4ee3\u7801\u793a\u4f8b',
+  Language: '\u8bed\u8a00',
+  'Code sample...': '\u793a\u4f8b\u4ee3\u7801...',
+  'Color Picker': '\u9009\u8272\u5668',
+  R: 'R',
+  G: 'G',
+  B: 'B',
+  'Left to right': '\u4ece\u5de6\u5230\u53f3',
+  'Right to left': '\u4ece\u53f3\u5230\u5de6',
+  Emoticons: '\u8868\u60c5',
+  'Emoticons...': '\u8868\u60c5\u7b26\u53f7...',
+  'Metadata and Document Properties': '\u5143\u6570\u636e\u548c\u6587\u6863\u5c5e\u6027',
+  Title: '\u6807\u9898',
+  Keywords: '\u5173\u952e\u8bcd',
+  Description: '\u63cf\u8ff0',
+  Robots: '\u673a\u5668\u4eba',
+  Author: '\u4f5c\u8005',
+  Encoding: '\u7f16\u7801',
+  Fullscreen: '\u5168\u5c4f',
+  Action: '\u64cd\u4f5c',
+  Shortcut: '\u5feb\u6377\u952e',
+  Help: '\u5e2e\u52a9',
+  Address: '\u5730\u5740',
+  'Focus to menubar': '\u79fb\u52a8\u7126\u70b9\u5230\u83dc\u5355\u680f',
+  'Focus to toolbar': '\u79fb\u52a8\u7126\u70b9\u5230\u5de5\u5177\u680f',
+  'Focus to element path': '\u79fb\u52a8\u7126\u70b9\u5230\u5143\u7d20\u8def\u5f84',
+  'Focus to contextual toolbar': '\u79fb\u52a8\u7126\u70b9\u5230\u4e0a\u4e0b\u6587\u83dc\u5355',
+  'Insert link (if link plugin activated)': '\u63d2\u5165\u94fe\u63a5 (\u5982\u679c\u94fe\u63a5\u63d2\u4ef6\u5df2\u6fc0\u6d3b)',
+  'Save (if save plugin activated)': '\u4fdd\u5b58(\u5982\u679c\u4fdd\u5b58\u63d2\u4ef6\u5df2\u6fc0\u6d3b)',
+  'Find (if searchreplace plugin activated)': '\u67e5\u627e(\u5982\u679c\u67e5\u627e\u66ff\u6362\u63d2\u4ef6\u5df2\u6fc0\u6d3b)',
+  'Plugins installed ({0}):': '\u5df2\u5b89\u88c5\u63d2\u4ef6 ({0}):',
+  'Premium plugins:': '\u4f18\u79c0\u63d2\u4ef6\uff1a',
+  'Learn more...': '\u4e86\u89e3\u66f4\u591a...',
+  'You are using {0}': '\u4f60\u6b63\u5728\u4f7f\u7528 {0}',
+  Plugins: '\u63d2\u4ef6',
+  'Handy Shortcuts': '\u5feb\u6377\u952e',
+  'Horizontal line': '\u6c34\u5e73\u5206\u5272\u7ebf',
+  'Insert/edit image': '\u63d2\u5165/\u7f16\u8f91\u56fe\u7247',
+  'Alternative description': '\u66ff\u4ee3\u63cf\u8ff0',
+  Accessibility: '\u8f85\u52a9\u529f\u80fd',
+  'Image is decorative': '\u56fe\u50cf\u662f\u88c5\u9970\u6027\u7684',
+  Source: '\u5730\u5740',
+  Dimensions: '\u5927\u5c0f',
+  'Constrain proportions': '\u4fdd\u6301\u7eb5\u6a2a\u6bd4',
+  General: '\u666e\u901a',
+  Advanced: '\u9ad8\u7ea7',
+  Style: '\u6837\u5f0f',
+  'Vertical space': '\u5782\u76f4\u8fb9\u8ddd',
+  'Horizontal space': '\u6c34\u5e73\u8fb9\u8ddd',
+  Border: '\u8fb9\u6846',
+  'Insert image': '\u63d2\u5165\u56fe\u7247',
+  'Image...': '\u56fe\u7247...',
+  'Image list': '\u56fe\u7247\u5217\u8868',
+  'Rotate counterclockwise': '\u9006\u65f6\u9488\u65cb\u8f6c',
+  'Rotate clockwise': '\u987a\u65f6\u9488\u65cb\u8f6c',
+  'Flip vertically': '\u5782\u76f4\u7ffb\u8f6c',
+  'Flip horizontally': '\u6c34\u5e73\u7ffb\u8f6c',
+  'Edit image': '\u7f16\u8f91\u56fe\u7247',
+  'Image options': '\u56fe\u7247\u9009\u9879',
+  'Zoom in': '\u653e\u5927',
+  'Zoom out': '\u7f29\u5c0f',
+  Crop: '\u88c1\u526a',
+  Resize: '\u8c03\u6574\u5927\u5c0f',
+  Orientation: '\u65b9\u5411',
+  Brightness: '\u4eae\u5ea6',
+  Sharpen: '\u9510\u5316',
+  Contrast: '\u5bf9\u6bd4\u5ea6',
+  'Color levels': '\u989c\u8272\u5c42\u6b21',
+  Gamma: '\u4f3d\u9a6c\u503c',
+  Invert: '\u53cd\u8f6c',
+  Apply: '\u5e94\u7528',
+  Back: '\u540e\u9000',
+  'Insert date/time': '\u63d2\u5165\u65e5\u671f/\u65f6\u95f4',
+  'Date/time': '\u65e5\u671f/\u65f6\u95f4',
+  'Insert/edit link': '\u63d2\u5165/\u7f16\u8f91\u94fe\u63a5',
+  'Text to display': '\u663e\u793a\u6587\u5b57',
+  Url: '\u5730\u5740',
+  'Open link in...': '\u94fe\u63a5\u6253\u5f00\u4f4d\u7f6e...',
+  'Current window': '\u5f53\u524d\u7a97\u53e3',
+  None: '\u65e0',
+  'New window': '\u5728\u65b0\u7a97\u53e3\u6253\u5f00',
+  'Open link': '\u6253\u5f00\u94fe\u63a5',
+  'Remove link': '\u5220\u9664\u94fe\u63a5',
+  Anchors: '\u951a\u70b9',
+  'Link...': '\u94fe\u63a5...',
+  'Paste or type a link': '\u7c98\u8d34\u6216\u8f93\u5165\u94fe\u63a5',
+  'The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?':
+    '\u4f60\u6240\u586b\u5199\u7684URL\u5730\u5740\u4e3a\u90ae\u4ef6\u5730\u5740\uff0c\u9700\u8981\u52a0\u4e0amailto:\u524d\u7f00\u5417\uff1f',
+  'The URL you entered seems to be an external link. Do you want to add the required http:// prefix?':
+    '\u4f60\u6240\u586b\u5199\u7684URL\u5730\u5740\u5c5e\u4e8e\u5916\u90e8\u94fe\u63a5\uff0c\u9700\u8981\u52a0\u4e0ahttp://:\u524d\u7f00\u5417\uff1f',
+  'The URL you entered seems to be an external link. Do you want to add the required https:// prefix?':
+    '\u60a8\u8f93\u5165\u7684 URL \u4f3c\u4e4e\u662f\u4e00\u4e2a\u5916\u90e8\u94fe\u63a5\u3002\u60a8\u60f3\u6dfb\u52a0\u6240\u9700\u7684 https:// \u524d\u7f00\u5417\uff1f',
+  'Link list': '\u94fe\u63a5\u5217\u8868',
+  'Insert video': '\u63d2\u5165\u89c6\u9891',
+  'Insert/edit video': '\u63d2\u5165/\u7f16\u8f91\u89c6\u9891',
+  'Insert/edit media': '\u63d2\u5165/\u7f16\u8f91\u5a92\u4f53',
+  'Alternative source': '\u955c\u50cf',
+  'Alternative source URL': '\u66ff\u4ee3\u6765\u6e90\u7f51\u5740',
+  'Media poster (Image URL)': '\u5c01\u9762(\u56fe\u7247\u5730\u5740)',
+  'Paste your embed code below:': '\u5c06\u5185\u5d4c\u4ee3\u7801\u7c98\u8d34\u5728\u4e0b\u9762:',
+  Embed: '\u5185\u5d4c',
+  'Media...': '\u591a\u5a92\u4f53...',
+  'Nonbreaking space': '\u4e0d\u95f4\u65ad\u7a7a\u683c',
+  'Page break': '\u5206\u9875\u7b26',
+  'Paste as text': '\u7c98\u8d34\u4e3a\u6587\u672c',
+  Preview: '\u9884\u89c8',
+  'Print...': '\u6253\u5370...',
+  Save: '\u4fdd\u5b58',
+  Find: '\u67e5\u627e',
+  'Replace with': '\u66ff\u6362\u4e3a',
+  Replace: '\u66ff\u6362',
+  'Replace all': '\u5168\u90e8\u66ff\u6362',
+  Previous: '\u4e0a\u4e00\u4e2a',
+  Next: '\u4e0b\u4e00\u4e2a',
+  'Find and Replace': '\u67e5\u627e\u548c\u66ff\u6362',
+  'Find and replace...': '\u67e5\u627e\u5e76\u66ff\u6362...',
+  'Could not find the specified string.': '\u672a\u627e\u5230\u641c\u7d22\u5185\u5bb9.',
+  'Match case': '\u533a\u5206\u5927\u5c0f\u5199',
+  'Find whole words only': '\u5168\u5b57\u5339\u914d',
+  'Find in selection': '\u5728\u9009\u533a\u4e2d\u67e5\u627e',
+  Spellcheck: '\u62fc\u5199\u68c0\u67e5',
+  'Spellcheck Language': '\u62fc\u5199\u68c0\u67e5\u8bed\u8a00',
+  'No misspellings found.': '\u6ca1\u6709\u53d1\u73b0\u62fc\u5199\u9519\u8bef',
+  Ignore: '\u5ffd\u7565',
+  'Ignore all': '\u5168\u90e8\u5ffd\u7565',
+  Finish: '\u5b8c\u6210',
+  'Add to Dictionary': '\u6dfb\u52a0\u5230\u5b57\u5178',
+  'Insert table': '\u63d2\u5165\u8868\u683c',
+  'Table properties': '\u8868\u683c\u5c5e\u6027',
+  'Delete table': '\u5220\u9664\u8868\u683c',
+  Cell: '\u5355\u5143\u683c',
+  Row: '\u884c',
+  Column: '\u5217',
+  'Cell properties': '\u5355\u5143\u683c\u5c5e\u6027',
+  'Merge cells': '\u5408\u5e76\u5355\u5143\u683c',
+  'Split cell': '\u62c6\u5206\u5355\u5143\u683c',
+  'Insert row before': '\u5728\u4e0a\u65b9\u63d2\u5165',
+  'Insert row after': '\u5728\u4e0b\u65b9\u63d2\u5165',
+  'Delete row': '\u5220\u9664\u884c',
+  'Row properties': '\u884c\u5c5e\u6027',
+  'Cut row': '\u526a\u5207\u884c',
+  'Copy row': '\u590d\u5236\u884c',
+  'Paste row before': '\u7c98\u8d34\u5230\u4e0a\u65b9',
+  'Paste row after': '\u7c98\u8d34\u5230\u4e0b\u65b9',
+  'Insert column before': '\u5728\u5de6\u4fa7\u63d2\u5165',
+  'Insert column after': '\u5728\u53f3\u4fa7\u63d2\u5165',
+  'Delete column': '\u5220\u9664\u5217',
+  Cols: '\u5217',
+  Rows: '\u884c',
+  Width: '\u5bbd',
+  Height: '\u9ad8',
+  'Cell spacing': '\u5355\u5143\u683c\u5916\u95f4\u8ddd',
+  'Cell padding': '\u5355\u5143\u683c\u5185\u8fb9\u8ddd',
+  Caption: '\u6807\u9898',
+  'Show caption': '\u663e\u793a\u6807\u9898',
+  Left: '\u5de6\u5bf9\u9f50',
+  Center: '\u5c45\u4e2d',
+  Right: '\u53f3\u5bf9\u9f50',
+  'Cell type': '\u5355\u5143\u683c\u7c7b\u578b',
+  Scope: '\u8303\u56f4',
+  Alignment: '\u5bf9\u9f50\u65b9\u5f0f',
+  'H Align': '\u6c34\u5e73\u5bf9\u9f50',
+  'V Align': '\u5782\u76f4\u5bf9\u9f50',
+  Top: '\u9876\u90e8\u5bf9\u9f50',
+  Middle: '\u5782\u76f4\u5c45\u4e2d',
+  Bottom: '\u5e95\u90e8\u5bf9\u9f50',
+  'Header cell': '\u8868\u5934\u5355\u5143\u683c',
+  'Row group': '\u884c\u7ec4',
+  'Column group': '\u5217\u7ec4',
+  'Row type': '\u884c\u7c7b\u578b',
+  Header: '\u8868\u5934',
+  Body: '\u8868\u4f53',
+  Footer: '\u8868\u5c3e',
+  'Border color': '\u8fb9\u6846\u989c\u8272',
+  'Insert template...': '\u63d2\u5165\u6a21\u677f...',
+  Templates: '\u6a21\u677f',
+  Template: '\u6a21\u677f',
+  'Text color': '\u6587\u5b57\u989c\u8272',
+  'Background color': '\u80cc\u666f\u8272',
+  'Custom...': '\u81ea\u5b9a\u4e49...',
+  'Custom color': '\u81ea\u5b9a\u4e49\u989c\u8272',
+  'No color': '\u65e0',
+  'Remove color': '\u79fb\u9664\u989c\u8272',
+  'Table of Contents': '\u5185\u5bb9\u5217\u8868',
+  'Show blocks': '\u663e\u793a\u533a\u5757\u8fb9\u6846',
+  'Show invisible characters': '\u663e\u793a\u4e0d\u53ef\u89c1\u5b57\u7b26',
+  'Word count': '\u5b57\u6570',
+  Count: '\u8ba1\u6570',
+  Document: '\u6587\u6863',
+  Selection: '\u9009\u62e9',
+  Words: '\u5355\u8bcd',
+  'Words: {0}': '\u5b57\u6570\uff1a{0}',
+  '{0} words': '{0} \u5b57',
+  File: '\u6587\u4ef6',
+  Edit: '\u7f16\u8f91',
+  Insert: '\u63d2\u5165',
+  View: '\u89c6\u56fe',
+  Format: '\u683c\u5f0f',
+  Table: '\u8868\u683c',
+  Tools: '\u5de5\u5177',
+  'Powered by {0}': '\u7531{0}\u9a71\u52a8',
+  'Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help':
+    '\u5728\u7f16\u8f91\u533a\u6309ALT-F9\u6253\u5f00\u83dc\u5355\uff0c\u6309ALT-F10\u6253\u5f00\u5de5\u5177\u680f\uff0c\u6309ALT-0\u67e5\u770b\u5e2e\u52a9',
+  'Image title': '\u56fe\u7247\u6807\u9898',
+  'Border width': '\u8fb9\u6846\u5bbd\u5ea6',
+  'Border style': '\u8fb9\u6846\u6837\u5f0f',
+  Error: '\u9519\u8bef',
+  Warn: '\u8b66\u544a',
+  Valid: '\u6709\u6548',
+  'To open the popup, press Shift+Enter': '\u6309Shitf+Enter\u952e\u6253\u5f00\u5bf9\u8bdd\u6846',
+  'Rich Text Area. Press ALT-0 for help.': '\u7f16\u8f91\u533a\u3002\u6309Alt+0\u952e\u6253\u5f00\u5e2e\u52a9\u3002',
+  'System Font': '\u7cfb\u7edf\u5b57\u4f53',
+  'Failed to upload image: {0}': '\u56fe\u7247\u4e0a\u4f20\u5931\u8d25: {0}',
+  'Failed to load plugin: {0} from url {1}': '\u63d2\u4ef6\u52a0\u8f7d\u5931\u8d25: {0} \u6765\u81ea\u94fe\u63a5 {1}',
+  'Failed to load plugin url: {0}': '\u63d2\u4ef6\u52a0\u8f7d\u5931\u8d25 \u94fe\u63a5: {0}',
+  'Failed to initialize plugin: {0}': '\u63d2\u4ef6\u521d\u59cb\u5316\u5931\u8d25: {0}',
+  example: '\u793a\u4f8b',
+  Search: '\u641c\u7d22',
+  All: '\u5168\u90e8',
+  Currency: '\u8d27\u5e01',
+  Text: '\u6587\u5b57',
+  Quotations: '\u5f15\u7528',
+  Mathematical: '\u6570\u5b66',
+  'Extended Latin': '\u62c9\u4e01\u8bed\u6269\u5145',
+  Symbols: '\u7b26\u53f7',
+  Arrows: '\u7bad\u5934',
+  'User Defined': '\u81ea\u5b9a\u4e49',
+  'dollar sign': '\u7f8e\u5143\u7b26\u53f7',
+  'currency sign': '\u8d27\u5e01\u7b26\u53f7',
+  'euro-currency sign': '\u6b27\u5143\u7b26\u53f7',
+  'colon sign': '\u5192\u53f7',
+  'cruzeiro sign': '\u514b\u9c81\u8d5b\u7f57\u5e01\u7b26\u53f7',
+  'french franc sign': '\u6cd5\u90ce\u7b26\u53f7',
+  'lira sign': '\u91cc\u62c9\u7b26\u53f7',
+  'mill sign': '\u5bc6\u5c14\u7b26\u53f7',
+  'naira sign': '\u5948\u62c9\u7b26\u53f7',
+  'peseta sign': '\u6bd4\u585e\u5854\u7b26\u53f7',
+  'rupee sign': '\u5362\u6bd4\u7b26\u53f7',
+  'won sign': '\u97e9\u5143\u7b26\u53f7',
+  'new sheqel sign': '\u65b0\u8c22\u514b\u5c14\u7b26\u53f7',
+  'dong sign': '\u8d8a\u5357\u76fe\u7b26\u53f7',
+  'kip sign': '\u8001\u631d\u57fa\u666e\u7b26\u53f7',
+  'tugrik sign': '\u56fe\u683c\u91cc\u514b\u7b26\u53f7',
+  'drachma sign': '\u5fb7\u62c9\u514b\u9a6c\u7b26\u53f7',
+  'german penny symbol': '\u5fb7\u56fd\u4fbf\u58eb\u7b26\u53f7',
+  'peso sign': '\u6bd4\u7d22\u7b26\u53f7',
+  'guarani sign': '\u74dc\u62c9\u5c3c\u7b26\u53f7',
+  'austral sign': '\u6fb3\u5143\u7b26\u53f7',
+  'hryvnia sign': '\u683c\u91cc\u592b\u5c3c\u4e9a\u7b26\u53f7',
+  'cedi sign': '\u585e\u5730\u7b26\u53f7',
+  'livre tournois sign': '\u91cc\u5f17\u5f17\u5c14\u7b26\u53f7',
+  'spesmilo sign': 'spesmilo\u7b26\u53f7',
+  'tenge sign': '\u575a\u6208\u7b26\u53f7',
+  'indian rupee sign': '\u5370\u5ea6\u5362\u6bd4',
+  'turkish lira sign': '\u571f\u8033\u5176\u91cc\u62c9',
+  'nordic mark sign': '\u5317\u6b27\u9a6c\u514b',
+  'manat sign': '\u9a6c\u7eb3\u7279\u7b26\u53f7',
+  'ruble sign': '\u5362\u5e03\u7b26\u53f7',
+  'yen character': '\u65e5\u5143\u5b57\u6837',
+  'yuan character': '\u4eba\u6c11\u5e01\u5143\u5b57\u6837',
+  'yuan character, in hong kong and taiwan': '\u5143\u5b57\u6837\uff08\u6e2f\u53f0\u5730\u533a\uff09',
+  'yen/yuan character variant one': '\u5143\u5b57\u6837\uff08\u5927\u5199\uff09',
+  'Loading emoticons...': '\u52a0\u8f7d\u8868\u60c5\u7b26\u53f7...',
+  'Could not load emoticons': '\u4e0d\u80fd\u52a0\u8f7d\u8868\u60c5\u7b26\u53f7',
+  People: '\u4eba\u7c7b',
+  'Animals and Nature': '\u52a8\u7269\u548c\u81ea\u7136',
+  'Food and Drink': '\u98df\u7269\u548c\u996e\u54c1',
+  Activity: '\u6d3b\u52a8',
+  'Travel and Places': '\u65c5\u6e38\u548c\u5730\u70b9',
+  Objects: '\u7269\u4ef6',
+  Flags: '\u65d7\u5e1c',
+  Characters: '\u5b57\u7b26',
+  'Characters (no spaces)': '\u5b57\u7b26(\u65e0\u7a7a\u683c)',
+  '{0} characters': '{0} \u4e2a\u5b57\u7b26',
+  'Error: Form submit field collision.': '\u9519\u8bef: \u8868\u5355\u63d0\u4ea4\u5b57\u6bb5\u51b2\u7a81\u3002',
+  'Error: No form element found.': '\u9519\u8bef: \u6ca1\u6709\u8868\u5355\u63a7\u4ef6\u3002',
+  Update: '\u66f4\u65b0',
+  'Color swatch': '\u989c\u8272\u6837\u672c',
+  Turquoise: '\u9752\u7eff\u8272',
+  Green: '\u7eff\u8272',
+  Blue: '\u84dd\u8272',
+  Purple: '\u7d2b\u8272',
+  'Navy Blue': '\u6d77\u519b\u84dd',
+  'Dark Turquoise': '\u6df1\u84dd\u7eff\u8272',
+  'Dark Green': '\u6df1\u7eff\u8272',
+  'Medium Blue': '\u4e2d\u84dd\u8272',
+  'Medium Purple': '\u4e2d\u7d2b\u8272',
+  'Midnight Blue': '\u6df1\u84dd\u8272',
+  Yellow: '\u9ec4\u8272',
+  Orange: '\u6a59\u8272',
+  Red: '\u7ea2\u8272',
+  'Light Gray': '\u6d45\u7070\u8272',
+  Gray: '\u7070\u8272',
+  'Dark Yellow': '\u6697\u9ec4\u8272',
+  'Dark Orange': '\u6df1\u6a59\u8272',
+  'Dark Red': '\u6df1\u7ea2\u8272',
+  'Medium Gray': '\u4e2d\u7070\u8272',
+  'Dark Gray': '\u6df1\u7070\u8272',
+  'Light Green': '\u6d45\u7eff\u8272',
+  'Light Yellow': '\u6d45\u9ec4\u8272',
+  'Light Red': '\u6d45\u7ea2\u8272',
+  'Light Purple': '\u6d45\u7d2b\u8272',
+  'Light Blue': '\u6d45\u84dd\u8272',
+  'Dark Purple': '\u6df1\u7d2b\u8272',
+  'Dark Blue': '\u6df1\u84dd\u8272',
+  Black: '\u9ed1\u8272',
+  White: '\u767d\u8272',
+  'Switch to or from fullscreen mode': '\u5207\u6362\u5168\u5c4f\u6a21\u5f0f',
+  'Open help dialog': '\u6253\u5f00\u5e2e\u52a9\u5bf9\u8bdd\u6846',
+  history: '\u5386\u53f2',
+  styles: '\u6837\u5f0f',
+  formatting: '\u683c\u5f0f\u5316',
+  alignment: '\u5bf9\u9f50',
+  indentation: '\u7f29\u8fdb',
+  Font: '\u5b57\u4f53',
+  Size: '\u5b57\u53f7',
+  'More...': '\u66f4\u591a...',
+  'Select...': '\u9009\u62e9...',
+  Preferences: '\u9996\u9009\u9879',
+  Yes: '\u662f',
+  No: '\u5426',
+  'Keyboard Navigation': '\u952e\u76d8\u6307\u5f15',
+  Version: '\u7248\u672c',
+  'Code view': '\u4ee3\u7801\u89c6\u56fe',
+  'Open popup menu for split buttons': '\u6253\u5f00\u5f39\u51fa\u5f0f\u83dc\u5355\uff0c\u7528\u4e8e\u62c6\u5206\u6309\u94ae',
+  'List Properties': '\u5217\u8868\u5c5e\u6027',
+  'List properties...': '\u6807\u9898\u5b57\u4f53\u5c5e\u6027',
+  'Start list at number': '\u4ee5\u6570\u5b57\u5f00\u59cb\u5217\u8868',
+  'Line height': '\u884c\u9ad8',
+  comments: '\u5907\u6ce8',
+  'Format Painter': '\u683c\u5f0f\u5237',
+  'Insert/edit iframe': '\u63d2\u5165/\u7f16\u8f91\u6846\u67b6',
+  Capitalization: '\u5927\u5199',
+  lowercase: '\u5c0f\u5199',
+  UPPERCASE: '\u5927\u5199',
+  'Title Case': '\u9996\u5b57\u6bcd\u5927\u5199',
+  'permanent pen': '\u8bb0\u53f7\u7b14',
+  'Permanent Pen Properties': '\u6c38\u4e45\u7b14\u5c5e\u6027',
+  'Permanent pen properties...': '\u6c38\u4e45\u7b14\u5c5e\u6027...',
+  'case change': '\u6848\u4f8b\u66f4\u6539',
+  'page embed': '\u9875\u9762\u5d4c\u5165',
+  'Advanced sort...': '\u9ad8\u7ea7\u6392\u5e8f...',
+  'Advanced Sort': '\u9ad8\u7ea7\u6392\u5e8f',
+  'Sort table by column ascending': '\u6309\u5217\u5347\u5e8f\u8868',
+  'Sort table by column descending': '\u6309\u5217\u964d\u5e8f\u8868',
+  Sort: '\u6392\u5e8f',
+  Order: '\u6392\u5e8f',
+  'Sort by': '\u6392\u5e8f\u65b9\u5f0f',
+  Ascending: '\u5347\u5e8f',
+  Descending: '\u964d\u5e8f',
+  'Column {0}': '\u5217{0}',
+  'Row {0}': '\u884c{0}',
+  'Spellcheck...': '\u62fc\u5199\u68c0\u67e5...',
+  'Misspelled word': '\u62fc\u5199\u9519\u8bef\u7684\u5355\u8bcd',
+  Suggestions: '\u5efa\u8bae',
+  Change: '\u66f4\u6539',
+  'Finding word suggestions': '\u67e5\u627e\u5355\u8bcd\u5efa\u8bae',
+  Success: '\u6210\u529f',
+  Repair: '\u4fee\u590d',
+  'Issue {0} of {1}': '\u5171\u8ba1{1}\u95ee\u9898{0}',
+  'Images must be marked as decorative or have an alternative text description':
+    '\u56fe\u50cf\u5fc5\u987b\u6807\u8bb0\u4e3a\u88c5\u9970\u6027\u6216\u5177\u6709\u66ff\u4ee3\u6587\u672c\u63cf\u8ff0',
+  'Images must have an alternative text description. Decorative images are not allowed.':
+    '\u56fe\u50cf\u5fc5\u987b\u5177\u6709\u66ff\u4ee3\u6587\u672c\u63cf\u8ff0\u3002\u4e0d\u5141\u8bb8\u4f7f\u7528\u88c5\u9970\u56fe\u50cf\u3002',
+  'Or provide alternative text:': '\u6216\u63d0\u4f9b\u5907\u9009\u6587\u672c\uff1a',
+  'Make image decorative:': '\u4f7f\u56fe\u50cf\u88c5\u9970\uff1a',
+  'ID attribute must be unique': 'ID \u5c5e\u6027\u5fc5\u987b\u662f\u552f\u4e00\u7684',
+  'Make ID unique': '\u4f7f ID \u72ec\u4e00\u65e0\u4e8c',
+  'Keep this ID and remove all others': '\u4fdd\u7559\u6b64 ID \u5e76\u5220\u9664\u6240\u6709\u5176\u4ed6',
+  'Remove this ID': '\u5220\u9664\u6b64 ID',
+  'Remove all IDs': '\u6e05\u9664\u5168\u90e8IDs',
+  Checklist: '\u6e05\u5355',
+  Anchor: '\u951a\u70b9',
+  'Special character': '\u7279\u6b8a\u7b26\u53f7',
+  'Code sample': '\u4ee3\u7801\u793a\u4f8b',
+  Color: '\u989c\u8272',
+  'Document properties': '\u6587\u6863\u5c5e\u6027',
+  'Image description': '\u56fe\u7247\u63cf\u8ff0',
+  Image: '\u56fe\u7247',
+  'Insert link': '\u63d2\u5165\u94fe\u63a5',
+  Target: '\u6253\u5f00\u65b9\u5f0f',
+  Link: '\u94fe\u63a5',
+  Poster: '\u5c01\u9762',
+  Media: '\u5a92\u4f53',
+  Print: '\u6253\u5370',
+  Prev: '\u4e0a\u4e00\u4e2a',
+  'Find and replace': '\u67e5\u627e\u548c\u66ff\u6362',
+  'Whole words': '\u5168\u5b57\u5339\u914d',
+  'Insert template': '\u63d2\u5165\u6a21\u677f',
+});

+ 425 - 0
public/tinymce/langs/zh_TW.js

@@ -0,0 +1,425 @@
+tinymce.addI18n('zh_TW', {
+  Redo: '\u91cd\u505a',
+  Undo: '\u64a4\u92b7',
+  Cut: '\u526a\u4e0b',
+  Copy: '\u8907\u88fd',
+  Paste: '\u8cbc\u4e0a',
+  'Select all': '\u5168\u9078',
+  'New document': '\u65b0\u6587\u4ef6',
+  Ok: '\u78ba\u5b9a',
+  Cancel: '\u53d6\u6d88',
+  'Visual aids': '\u5c0f\u5e6b\u624b',
+  Bold: '\u7c97\u9ad4',
+  Italic: '\u659c\u9ad4',
+  Underline: '\u4e0b\u5283\u7dda',
+  Strikethrough: '\u522a\u9664\u7dda',
+  Superscript: '\u4e0a\u6a19',
+  Subscript: '\u4e0b\u6a19',
+  'Clear formatting': '\u6e05\u9664\u683c\u5f0f',
+  'Align left': '\u5de6\u908a\u5c0d\u9f4a',
+  'Align center': '\u4e2d\u9593\u5c0d\u9f4a',
+  'Align right': '\u53f3\u908a\u5c0d\u9f4a',
+  Justify: '\u5de6\u53f3\u5c0d\u9f4a',
+  'Bullet list': '\u9805\u76ee\u6e05\u55ae',
+  'Numbered list': '\u6578\u5b57\u6e05\u55ae',
+  'Decrease indent': '\u6e1b\u5c11\u7e2e\u6392',
+  'Increase indent': '\u589e\u52a0\u7e2e\u6392',
+  Close: '\u95dc\u9589',
+  Formats: '\u683c\u5f0f',
+  "Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X/C/V keyboard shortcuts instead.":
+    '\u60a8\u7684\u700f\u89bd\u5668\u4e0d\u652f\u63f4\u5b58\u53d6\u526a\u8cbc\u7c3f\uff0c\u53ef\u4ee5\u4f7f\u7528\u5feb\u901f\u9375 Ctrl + X/C/V \u4ee3\u66ff\u526a\u4e0b\u3001\u8907\u88fd\u8207\u8cbc\u4e0a\u3002',
+  Headers: '\u6a19\u984c',
+  'Header 1': '\u6a19\u984c 1',
+  'Header 2': '\u6a19\u984c 2',
+  'Header 3': '\u6a19\u984c 3',
+  'Header 4': '\u6a19\u984c 4',
+  'Header 5': '\u6a19\u984c 5',
+  'Header 6': '\u6a19\u984c 6',
+  Headings: '\u6a19\u984c',
+  'Heading 1': '\u6a19\u984c1',
+  'Heading 2': '\u6a19\u984c2',
+  'Heading 3': '\u6a19\u984c3',
+  'Heading 4': '\u6a19\u984c4',
+  'Heading 5': '\u6a19\u984c5',
+  'Heading 6': '\u6a19\u984c6',
+  Preformatted: '\u9810\u5148\u683c\u5f0f\u5316\u7684',
+  Div: 'Div',
+  Pre: 'Pre',
+  Code: '\u4ee3\u78bc',
+  Paragraph: '\u6bb5\u843d',
+  Blockquote: '\u5f15\u6587\u5340\u584a',
+  Inline: '\u5167\u806f',
+  Blocks: '\u57fa\u584a',
+  'Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.':
+    '\u76ee\u524d\u5c07\u4ee5\u7d14\u6587\u5b57\u7684\u6a21\u5f0f\u8cbc\u4e0a\uff0c\u60a8\u53ef\u4ee5\u518d\u9ede\u9078\u4e00\u6b21\u53d6\u6d88\u3002',
+  Fonts: '\u5b57\u578b',
+  'Font Sizes': '\u5b57\u578b\u5927\u5c0f',
+  Class: '\u985e\u578b',
+  'Browse for an image': '\u5f9e\u5716\u7247\u4e2d\u700f\u89bd',
+  OR: '\u6216',
+  'Drop an image here': '\u62d6\u66f3\u5716\u7247\u81f3\u6b64',
+  Upload: '\u4e0a\u50b3',
+  Block: '\u5340\u584a',
+  Align: '\u5c0d\u9f4a',
+  Default: '\u9810\u8a2d',
+  Circle: '\u7a7a\u5fc3\u5713',
+  Disc: '\u5be6\u5fc3\u5713',
+  Square: '\u6b63\u65b9\u5f62',
+  'Lower Alpha': '\u5c0f\u5beb\u82f1\u6587\u5b57\u6bcd',
+  'Lower Greek': '\u5e0c\u81d8\u5b57\u6bcd',
+  'Lower Roman': '\u5c0f\u5beb\u7f85\u99ac\u6578\u5b57',
+  'Upper Alpha': '\u5927\u5beb\u82f1\u6587\u5b57\u6bcd',
+  'Upper Roman': '\u5927\u5beb\u7f85\u99ac\u6578\u5b57',
+  'Anchor...': '\u9328\u9ede...',
+  Name: '\u540d\u7a31',
+  Id: 'Id',
+  'Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.':
+    'Id\u61c9\u4ee5\u5b57\u6bcd\u958b\u982d\uff0c\u5f8c\u9762\u63a5\u8457\u5b57\u6bcd\uff0c\u6578\u5b57\uff0c\u7834\u6298\u865f\uff0c\u9ede\u6578\uff0c\u5192\u865f\u6216\u4e0b\u5283\u7dda\u3002',
+  'You have unsaved changes are you sure you want to navigate away?': '\u7de8\u8f2f\u5c1a\u672a\u88ab\u5132\u5b58\uff0c\u4f60\u78ba\u5b9a\u8981\u96e2\u958b\uff1f',
+  'Restore last draft': '\u8f09\u5165\u4e0a\u4e00\u6b21\u7de8\u8f2f\u7684\u8349\u7a3f',
+  'Special character...': '\u7279\u6b8a\u5b57\u5143......',
+  'Source code': '\u539f\u59cb\u78bc',
+  'Insert/Edit code sample': '\u63d2\u5165/\u7de8\u8f2f \u7a0b\u5f0f\u78bc\u7bc4\u4f8b',
+  Language: '\u8a9e\u8a00',
+  'Code sample...': '\u7a0b\u5f0f\u78bc\u7bc4\u4f8b...',
+  'Color Picker': '\u9078\u8272\u5668',
+  R: '\u7d05',
+  G: '\u7da0',
+  B: '\u85cd',
+  'Left to right': '\u5f9e\u5de6\u5230\u53f3',
+  'Right to left': '\u5f9e\u53f3\u5230\u5de6',
+  'Emoticons...': '\u8868\u60c5\u7b26\u865f\u2026',
+  'Metadata and Document Properties': '\u5f8c\u8a2d\u8cc7\u6599\u8207\u6587\u4ef6\u5c6c\u6027',
+  Title: '\u6a19\u984c',
+  Keywords: '\u95dc\u9375\u5b57',
+  Description: '\u63cf\u8ff0',
+  Robots: '\u6a5f\u5668\u4eba',
+  Author: '\u4f5c\u8005',
+  Encoding: '\u7de8\u78bc',
+  Fullscreen: '\u5168\u87a2\u5e55',
+  Action: '\u52d5\u4f5c',
+  Shortcut: '\u5feb\u901f\u9375',
+  Help: '\u5e6b\u52a9',
+  Address: '\u5730\u5740',
+  'Focus to menubar': '\u8df3\u81f3\u9078\u55ae\u5217',
+  'Focus to toolbar': '\u8df3\u81f3\u5de5\u5177\u5217',
+  'Focus to element path': '\u8df3\u81f3HTML\u5143\u7d20\u5217',
+  'Focus to contextual toolbar': '\u8df3\u81f3\u5feb\u6377\u9078\u55ae',
+  'Insert link (if link plugin activated)': '\u65b0\u589e\u6377\u5f91 (\u6377\u5f91\u5916\u639b\u555f\u7528\u6642)',
+  'Save (if save plugin activated)': '\u5132\u5b58 (\u5132\u5b58\u5916\u639b\u555f\u7528\u6642)',
+  'Find (if searchreplace plugin activated)': '\u5c0b\u627e (\u5c0b\u627e\u53d6\u4ee3\u5916\u639b\u555f\u7528\u6642)',
+  'Plugins installed ({0}):': '({0}) \u500b\u5916\u639b\u5df2\u5b89\u88dd\uff1a',
+  'Premium plugins:': '\u52a0\u503c\u5916\u639b\uff1a',
+  'Learn more...': '\u4e86\u89e3\u66f4\u591a...',
+  'You are using {0}': '\u60a8\u6b63\u5728\u4f7f\u7528 {0}',
+  Plugins: '\u5916\u639b',
+  'Handy Shortcuts': '\u5feb\u901f\u9375',
+  'Horizontal line': '\u6c34\u5e73\u7dda',
+  'Insert/edit image': '\u63d2\u5165/\u7de8\u8f2f \u5716\u7247',
+  'Image description': '\u5716\u7247\u63cf\u8ff0',
+  Source: '\u5716\u7247\u7db2\u5740',
+  Dimensions: '\u5c3a\u5bf8',
+  'Constrain proportions': '\u7b49\u6bd4\u4f8b\u7e2e\u653e',
+  General: '\u4e00\u822c',
+  Advanced: '\u9032\u968e',
+  Style: '\u6a23\u5f0f',
+  'Vertical space': '\u9ad8\u5ea6',
+  'Horizontal space': '\u5bec\u5ea6',
+  Border: '\u908a\u6846',
+  'Insert image': '\u63d2\u5165\u5716\u7247',
+  'Image...': '\u5716\u7247......',
+  'Image list': '\u5716\u7247\u6e05\u55ae',
+  'Rotate counterclockwise': '\u9006\u6642\u91dd\u65cb\u8f49',
+  'Rotate clockwise': '\u9806\u6642\u91dd\u65cb\u8f49',
+  'Flip vertically': '\u5782\u76f4\u7ffb\u8f49',
+  'Flip horizontally': '\u6c34\u5e73\u7ffb\u8f49',
+  'Edit image': '\u7de8\u8f2f\u5716\u7247',
+  'Image options': '\u5716\u7247\u9078\u9805',
+  'Zoom in': '\u653e\u5927',
+  'Zoom out': '\u7e2e\u5c0f',
+  Crop: '\u88c1\u526a',
+  Resize: '\u8abf\u6574\u5927\u5c0f',
+  Orientation: '\u65b9\u5411',
+  Brightness: '\u4eae\u5ea6',
+  Sharpen: '\u92b3\u5316',
+  Contrast: '\u5c0d\u6bd4',
+  'Color levels': '\u984f\u8272\u5c64\u6b21',
+  Gamma: '\u4f3d\u99ac\u503c',
+  Invert: '\u53cd\u8f49',
+  Apply: '\u61c9\u7528',
+  Back: '\u5f8c\u9000',
+  'Insert date/time': '\u63d2\u5165 \u65e5\u671f/\u6642\u9593',
+  'Date/time': '\u65e5\u671f/\u6642\u9593',
+  'Insert/Edit Link': '\u63d2\u5165/\u7de8\u8f2f\u9023\u7d50',
+  'Insert/edit link': '\u63d2\u5165/\u7de8\u8f2f\u9023\u7d50',
+  'Text to display': '\u986f\u793a\u6587\u5b57',
+  Url: '\u7db2\u5740',
+  'Open link in...': '\u958b\u555f\u9023\u7d50\u65bc...',
+  'Current window': '\u76ee\u524d\u8996\u7a97',
+  None: '\u7121',
+  'New window': '\u53e6\u958b\u8996\u7a97',
+  'Remove link': '\u79fb\u9664\u9023\u7d50',
+  Anchors: '\u52a0\u5165\u9328\u9ede',
+  'Link...': '\u9023\u7d50...',
+  'Paste or type a link': '\u8cbc\u4e0a\u6216\u8f38\u5165\u9023\u7d50',
+  'The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?':
+    '\u4f60\u6240\u586b\u5beb\u7684URL\u70ba\u96fb\u5b50\u90f5\u4ef6\uff0c\u9700\u8981\u52a0\u4e0amailto:\u524d\u7db4\u55ce\uff1f',
+  'The URL you entered seems to be an external link. Do you want to add the required http:// prefix?':
+    '\u4f60\u6240\u586b\u5beb\u7684URL\u5c6c\u65bc\u5916\u90e8\u93c8\u63a5\uff0c\u9700\u8981\u52a0\u4e0ahttp://:\u524d\u7db4\u55ce\uff1f',
+  'Link list': '\u9023\u7d50\u6e05\u55ae',
+  'Insert video': '\u63d2\u5165\u5f71\u97f3',
+  'Insert/edit video': '\u63d2\u4ef6/\u7de8\u8f2f \u5f71\u97f3',
+  'Insert/edit media': '\u63d2\u5165/\u7de8\u8f2f \u5a92\u9ad4',
+  'Alternative source': '\u66ff\u4ee3\u5f71\u97f3',
+  'Alternative source URL': '\u66ff\u4ee3\u4f86\u6e90URL',
+  'Media poster (Image URL)': '\u5a92\u9ad4\u6d77\u5831\uff08\u5f71\u50cfImage URL\uff09',
+  'Paste your embed code below:': '\u8acb\u5c07\u60a8\u7684\u5d4c\u5165\u5f0f\u7a0b\u5f0f\u78bc\u8cbc\u5728\u4e0b\u9762:',
+  Embed: '\u5d4c\u5165\u78bc',
+  'Media...': '\u5a92\u9ad4...',
+  'Nonbreaking space': '\u4e0d\u5206\u884c\u7684\u7a7a\u683c',
+  'Page break': '\u5206\u9801',
+  'Paste as text': '\u4ee5\u7d14\u6587\u5b57\u8cbc\u4e0a',
+  Preview: '\u9810\u89bd',
+  'Print...': '\u5217\u5370...',
+  Save: '\u5132\u5b58',
+  Find: '\u641c\u5c0b',
+  'Replace with': '\u66f4\u63db',
+  Replace: '\u66ff\u63db',
+  'Replace all': '\u66ff\u63db\u5168\u90e8',
+  Previous: '\u4e0a\u4e00\u500b',
+  Next: '\u4e0b\u4e00\u500b',
+  'Find and replace...': '\u5c0b\u627e\u53ca\u53d6\u4ee3...',
+  'Could not find the specified string.': '\u7121\u6cd5\u67e5\u8a62\u5230\u6b64\u7279\u5b9a\u5b57\u4e32',
+  'Match case': '\u76f8\u5339\u914d\u6848\u4ef6',
+  'Find whole words only': '\u50c5\u627e\u51fa\u5b8c\u6574\u5b57\u532f',
+  'Spell check': '\u62fc\u5beb\u6aa2\u67e5',
+  Ignore: '\u5ffd\u7565',
+  'Ignore all': '\u5ffd\u7565\u6240\u6709',
+  Finish: '\u5b8c\u6210',
+  'Add to Dictionary': '\u52a0\u5165\u5b57\u5178\u4e2d',
+  'Insert table': '\u63d2\u5165\u8868\u683c',
+  'Table properties': '\u8868\u683c\u5c6c\u6027',
+  'Delete table': '\u522a\u9664\u8868\u683c',
+  Cell: '\u5132\u5b58\u683c',
+  Row: '\u5217',
+  Column: '\u884c',
+  'Cell properties': '\u5132\u5b58\u683c\u5c6c\u6027',
+  'Merge cells': '\u5408\u4f75\u5132\u5b58\u683c',
+  'Split cell': '\u5206\u5272\u5132\u5b58\u683c',
+  'Insert row before': '\u63d2\u5165\u5217\u5728...\u4e4b\u524d',
+  'Insert row after': '\u63d2\u5165\u5217\u5728...\u4e4b\u5f8c',
+  'Delete row': '\u522a\u9664\u5217',
+  'Row properties': '\u5217\u5c6c\u6027',
+  'Cut row': '\u526a\u4e0b\u5217',
+  'Copy row': '\u8907\u88fd\u5217',
+  'Paste row before': '\u8cbc\u4e0a\u5217\u5728...\u4e4b\u524d',
+  'Paste row after': '\u8cbc\u4e0a\u5217\u5728...\u4e4b\u5f8c',
+  'Insert column before': '\u63d2\u5165\u6b04\u4f4d\u5728...\u4e4b\u524d',
+  'Insert column after': '\u63d2\u5165\u6b04\u4f4d\u5728...\u4e4b\u5f8c',
+  'Delete column': '\u522a\u9664\u884c',
+  Cols: '\u6b04\u4f4d\u6bb5',
+  Rows: '\u5217',
+  Width: '\u5bec\u5ea6',
+  Height: '\u9ad8\u5ea6',
+  'Cell spacing': '\u5132\u5b58\u683c\u5f97\u9593\u8ddd',
+  'Cell padding': '\u5132\u5b58\u683c\u7684\u908a\u8ddd',
+  'Show caption': '\u986f\u793a\u6a19\u984c',
+  Left: '\u5de6\u908a',
+  Center: '\u4e2d\u9593',
+  Right: '\u53f3\u908a',
+  'Cell type': '\u5132\u5b58\u683c\u7684\u985e\u578b',
+  Scope: '\u7bc4\u570d',
+  Alignment: '\u5c0d\u9f4a',
+  'H Align': '\u6c34\u5e73\u4f4d\u7f6e',
+  'V Align': '\u5782\u76f4\u4f4d\u7f6e',
+  Top: '\u7f6e\u9802',
+  Middle: '\u7f6e\u4e2d',
+  Bottom: '\u7f6e\u5e95',
+  'Header cell': '\u6a19\u982d\u5132\u5b58\u683c',
+  'Row group': '\u5217\u7fa4\u7d44',
+  'Column group': '\u6b04\u4f4d\u7fa4\u7d44',
+  'Row type': '\u884c\u7684\u985e\u578b',
+  Header: '\u6a19\u982d',
+  Body: '\u4e3b\u9ad4',
+  Footer: '\u9801\u5c3e',
+  'Border color': '\u908a\u6846\u984f\u8272',
+  'Insert template...': '\u63d2\u5165\u6a23\u7248...',
+  Templates: '\u6a23\u7248',
+  Template: '\u6a23\u677f',
+  'Text color': '\u6587\u5b57\u984f\u8272',
+  'Background color': '\u80cc\u666f\u984f\u8272',
+  'Custom...': '\u81ea\u8a02',
+  'Custom color': '\u81ea\u8a02\u984f\u8272',
+  'No color': 'No color',
+  'Remove color': '\u79fb\u9664\u984f\u8272',
+  'Table of Contents': '\u76ee\u9304',
+  'Show blocks': '\u986f\u793a\u5340\u584a\u8cc7\u8a0a',
+  'Show invisible characters': '\u986f\u793a\u96b1\u85cf\u5b57\u5143',
+  'Word count': '\u8a08\u7b97\u5b57\u6578',
+  Count: '\u8a08\u7b97',
+  Document: '\u6587\u4ef6',
+  Selection: '\u9078\u9805',
+  Words: '\u5b57\u6578',
+  'Words: {0}': '\u5b57\u6578\uff1a{0}',
+  '{0} words': '{0} \u5b57\u5143',
+  File: '\u6a94\u6848',
+  Edit: '\u7de8\u8f2f',
+  Insert: '\u63d2\u5165',
+  View: '\u6aa2\u8996',
+  Format: '\u683c\u5f0f',
+  Table: '\u8868\u683c',
+  Tools: '\u5de5\u5177',
+  'Powered by {0}': '\u7531 {0} \u63d0\u4f9b',
+  'Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help':
+    '\u8c50\u5bcc\u7684\u6587\u672c\u5340\u57df\u3002\u6309ALT-F9\u524d\u5f80\u4e3b\u9078\u55ae\u3002\u6309ALT-F10\u547c\u53eb\u5de5\u5177\u6b04\u3002\u6309ALT-0\u5c0b\u6c42\u5e6b\u52a9',
+  'Image title': '\u5716\u7247\u6a19\u984c',
+  'Border width': '\u6846\u7dda\u5bec\u5ea6',
+  'Border style': '\u6846\u7dda\u6a23\u5f0f',
+  Error: '\u932f\u8aa4',
+  Warn: '\u8b66\u544a',
+  Valid: '\u6709\u6548',
+  'To open the popup, press Shift+Enter': '\u8981\u958b\u555f\u5f48\u51fa\u8996\u7a97\uff0c\u8acb\u6309Shift+Enter',
+  'Rich Text Area. Press ALT-0 for help.': '\u5bcc\u6587\u672c\u5340\u57df\u3002\u8acb\u6309ALT-0\u5c0b\u6c42\u5354\u52a9\u3002',
+  'System Font': '\u7cfb\u7d71\u5b57\u578b',
+  'Failed to upload image: {0}': '\u7121\u6cd5\u4e0a\u50b3\u5f71\u50cf\uff1a{0}',
+  'Failed to load plugin: {0} from url {1}': '\u7121\u6cd5\u4e0a\u50b3\u63d2\u4ef6\uff1a{0}\u81eaurl{1}',
+  'Failed to load plugin url: {0}': '\u7121\u6cd5\u4e0a\u50b3\u63d2\u4ef6\uff1a{0}',
+  'Failed to initialize plugin: {0}': '\u7121\u6cd5\u555f\u52d5\u63d2\u4ef6\uff1a{0}',
+  example: '\u7bc4\u4f8b',
+  Search: '\u641c\u7d22',
+  All: '\u5168\u90e8',
+  Currency: '\u8ca8\u5e63',
+  Text: '\u6587\u672c',
+  Quotations: '\u5f15\u7528',
+  Mathematical: '\u6578\u5b78',
+  'Extended Latin': '\u62c9\u4e01\u5b57\u6bcd\u64f4\u5145',
+  Symbols: '\u7b26\u865f',
+  Arrows: '\u7bad\u982d',
+  'User Defined': '\u4f7f\u7528\u8005\u5df2\u5b9a\u7fa9',
+  'dollar sign': '\u7f8e\u5143\u7b26\u865f',
+  'currency sign': '\u8ca8\u5e63\u7b26\u865f',
+  'euro-currency sign': '\u6b50\u5143\u7b26\u865f',
+  'colon sign': '\u79d1\u6717\u7b26\u865f',
+  'cruzeiro sign': '\u514b\u9b6f\u8cfd\u7f85\u7b26\u865f',
+  'french franc sign': '\u6cd5\u6717\u7b26\u865f',
+  'lira sign': '\u91cc\u62c9\u7b26\u865f',
+  'mill sign': '\u6587\u7b26\u865f',
+  'naira sign': '\u5948\u62c9\u7b26\u865f',
+  'peseta sign': '\u6bd4\u585e\u5854\u7b26\u865f',
+  'rupee sign': '\u76e7\u6bd4\u7b26\u865f',
+  'won sign': '\u97d3\u571c\u7b26\u865f',
+  'new sheqel sign': '\u65b0\u8b1d\u514b\u723e\u7b26\u865f',
+  'dong sign': '\u8d8a\u5357\u76fe\u7b26\u865f',
+  'kip sign': '\u8001\u64be\u5e63\u7b26\u865f',
+  'tugrik sign': '\u8499\u53e4\u5e63\u7b26\u865f',
+  'drachma sign': '\u5fb7\u514b\u62c9\u99ac\u7b26\u865f',
+  'german penny symbol': '\u5fb7\u570b\u5206\u7b26\u865f',
+  'peso sign': '\u62ab\u7d22\u7b26\u865f',
+  'guarani sign': '\u5df4\u62c9\u572d\u5e63\u7b26\u865f',
+  'austral sign': '\u963f\u6839\u5ef7\u5e63\u7b26\u865f',
+  'hryvnia sign': '\u70cf\u514b\u862d\u5e63\u7b26\u865f',
+  'cedi sign': '\u8fe6\u7d0d\u5e63\u7b26\u865f',
+  'livre tournois sign': '\u91cc\u5f17\u723e\u7b26\u865f',
+  'spesmilo sign': '\u570b\u969b\u5e63\u7b26\u865f',
+  'tenge sign': '\u54c8\u85a9\u514b\u5e63\u7b26\u865f',
+  'indian rupee sign': '\u5370\u5ea6\u76e7\u6bd4\u7b26\u865f',
+  'turkish lira sign': '\u571f\u8033\u5176\u91cc\u62c9\u7b26\u865f',
+  'nordic mark sign': '\u5317\u6b50\u99ac\u514b\u7b26\u865f',
+  'manat sign': '\u4e9e\u585e\u62dc\u7136\u5e63\u7b26\u865f',
+  'ruble sign': '\u76e7\u5e03\u7b26\u865f',
+  'yen character': '\u65e5\u5713\u7b26\u865f',
+  'yuan character': '\u4eba\u6c11\u5e63\u7b26\u865f',
+  'yuan character, in hong kong and taiwan': '\u6e2f\u5143\u8207\u53f0\u5e63\u7b26\u865f',
+  'yen/yuan character variant one': '\u65e5\u5713/\u4eba\u6c11\u5e63\u7b26\u865f\u8b8a\u5316\u578b',
+  'Loading emoticons...': '\u8f09\u5165\u8868\u60c5\u7b26\u865f\u2026',
+  'Could not load emoticons': '\u7121\u6cd5\u8f09\u5165\u8868\u60c5\u7b26\u865f',
+  People: '\u4eba',
+  'Animals and Nature': '\u52d5\u7269\u8207\u81ea\u7136',
+  'Food and Drink': '\u98f2\u98df',
+  Activity: '\u6d3b\u52d5',
+  'Travel and Places': '\u65c5\u884c\u8207\u5730\u9ede',
+  Objects: '\u7269\u4ef6',
+  Flags: '\u65d7\u6a19',
+  Characters: '\u5b57\u5143',
+  'Characters (no spaces)': '\u5b57\u5143\uff08\u7121\u7a7a\u683c\uff09',
+  '{0} characters': '{0}\u5b57\u5143',
+  'Error: Form submit field collision.': '\u932f\u8aa4\uff1a\u8868\u683c\u905e\u4ea4\u6b04\u4f4d\u885d\u7a81\u3002',
+  'Error: No form element found.': '\u932f\u8aa4\uff1a\u627e\u4e0d\u5230\u8868\u683c\u5143\u7d20\u3002',
+  Update: '\u66f4\u65b0',
+  'Color swatch': '\u8272\u5f69\u6a23\u672c',
+  Turquoise: '\u571f\u8033\u5176\u85cd',
+  Green: '\u7da0\u8272',
+  Blue: '\u85cd\u8272',
+  Purple: '\u7d2b\u8272',
+  'Navy Blue': '\u6df1\u85cd\u8272',
+  'Dark Turquoise': '\u6df1\u571f\u8033\u5176\u85cd',
+  'Dark Green': '\u6df1\u7da0\u8272',
+  'Medium Blue': '\u4e2d\u85cd\u8272',
+  'Medium Purple': '\u4e2d\u7d2b\u8272',
+  'Midnight Blue': '\u9ed1\u85cd\u8272',
+  Yellow: '\u9ec3\u8272',
+  Orange: '\u6a59\u8272',
+  Red: '\u7d05\u8272',
+  'Light Gray': '\u6dfa\u7070\u8272',
+  Gray: '\u7070\u8272',
+  'Dark Yellow': '\u6df1\u9ec3\u8272',
+  'Dark Orange': '\u6df1\u6a59\u8272',
+  'Dark Red': '\u6697\u7d05\u8272',
+  'Medium Gray': '\u4e2d\u7070\u8272',
+  'Dark Gray': '\u6df1\u7070\u8272',
+  'Light Green': '\u6de1\u7da0\u8272',
+  'Light Yellow': '\u6dfa\u9ec3\u8272',
+  'Light Red': '\u6dfa\u7d05\u8272',
+  'Light Purple': '\u6dfa\u7d2b\u8272',
+  'Light Blue': '\u6dfa\u85cd\u8272',
+  'Dark Purple': '\u6df1\u7d2b\u8272',
+  'Dark Blue': '\u6df1\u85cd\u8272',
+  Black: '\u9ed1\u8272',
+  White: '\u767d\u8272',
+  'Switch to or from fullscreen mode': '\u8f49\u63db\u81ea/\u81f3\u5168\u87a2\u5e55\u6a21\u5f0f',
+  'Open help dialog': '\u958b\u555f\u5354\u52a9\u5c0d\u8a71',
+  history: '\u6b77\u53f2',
+  styles: '\u6a23\u5f0f',
+  formatting: '\u683c\u5f0f',
+  alignment: '\u5c0d\u9f4a',
+  indentation: '\u7e2e\u6392',
+  'permanent pen': '\u6c38\u4e45\u6027\u7b46',
+  comments: '\u8a3b\u89e3',
+  'Format Painter': '\u8907\u88fd\u683c\u5f0f',
+  'Insert/edit iframe': '\u63d2\u5165/\u7de8\u8f2fiframe',
+  Capitalization: '\u5927\u5beb',
+  lowercase: '\u5c0f\u5beb',
+  UPPERCASE: '\u5927\u5beb',
+  'Title Case': '\u5b57\u9996\u5927\u5beb',
+  'Permanent Pen Properties': '\u6c38\u4e45\u6a19\u8a18\u5c6c\u6027',
+  'Permanent pen properties...': '\u6c38\u4e45\u6a19\u8a18\u5c6c\u6027......',
+  Font: '\u5b57\u578b',
+  Size: '\u5b57\u5f62\u5927\u5c0f',
+  'More...': '\u66f4\u591a\u8cc7\u8a0a......',
+  'Spellcheck Language': '\u62fc\u5beb\u8a9e\u8a00',
+  'Select...': '\u9078\u64c7......',
+  Preferences: '\u9996\u9078\u9805',
+  Yes: '\u662f',
+  No: '\u5426',
+  'Keyboard Navigation': '\u9375\u76e4\u5c0e\u822a',
+  Version: '\u7248\u672c',
+  Anchor: '\u52a0\u5165\u9328\u9ede',
+  'Special character': '\u7279\u6b8a\u5b57\u5143',
+  'Code sample': '\u7a0b\u5f0f\u78bc\u7bc4\u4f8b',
+  Color: '\u984f\u8272',
+  Emoticons: '\u8868\u60c5',
+  'Document properties': '\u6587\u4ef6\u7684\u5c6c\u6027',
+  Image: '\u5716\u7247',
+  'Insert link': '\u63d2\u5165\u9023\u7d50',
+  Target: '\u958b\u555f\u65b9\u5f0f',
+  Link: '\u9023\u7d50',
+  Poster: '\u9810\u89bd\u5716\u7247',
+  Media: '\u5a92\u9ad4',
+  Print: '\u5217\u5370',
+  Prev: '\u4e0a\u4e00\u500b',
+  'Find and replace': '\u5c0b\u627e\u53ca\u53d6\u4ee3',
+  'Whole words': '\u6574\u500b\u55ae\u5b57',
+  Spellcheck: '\u62fc\u5b57\u6aa2\u67e5',
+  Caption: '\u8868\u683c\u6a19\u984c',
+  'Insert template': '\u63d2\u5165\u6a23\u7248',
+});

+ 7 - 0
public/tinymce/skins/content/dark/content.min.css

@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body{background-color:#2f3742;color:#dfe0e4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}a{color:#4099ff}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#6d737b}figure{display:table;margin:1rem auto}figure figcaption{color:#8a8f97;display:block;margin-top:.25rem;text-align:center}hr{border-color:#6d737b;border-style:solid;border-width:1px 0 0 0}code{background-color:#6d737b;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #6d737b;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #6d737b;margin-right:1.5rem;padding-right:1rem}

+ 7 - 0
public/tinymce/skins/content/default/content.min.css

@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}

+ 7 - 0
public/tinymce/skins/content/document/content.min.css

@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+@media screen{html{background:#f4f4f4;min-height:100%}}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif}@media screen{body{background-color:#fff;box-shadow:0 0 4px rgba(0,0,0,.15);box-sizing:border-box;margin:1rem auto 0;max-width:820px;min-height:calc(100vh - 1rem);padding:4rem 6rem 6rem 6rem}}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure figcaption{color:#999;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}

+ 7 - 0
public/tinymce/skins/content/writer/content.min.css

@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif;line-height:1.4;margin:1rem auto;max-width:900px}table{border-collapse:collapse}table:not([cellpadding]) td,table:not([cellpadding]) th{padding:.4rem}table[border]:not([border="0"]):not([style*=border-width]) td,table[border]:not([border="0"]):not([style*=border-width]) th{border-width:1px}table[border]:not([border="0"]):not([style*=border-style]) td,table[border]:not([border="0"]):not([style*=border-style]) th{border-style:solid}table[border]:not([border="0"]):not([style*=border-color]) td,table[border]:not([border="0"]):not([style*=border-color]) th{border-color:#ccc}figure{display:table;margin:1rem auto}figure figcaption{color:#999;display:block;margin-top:.25rem;text-align:center}hr{border-color:#ccc;border-style:solid;border-width:1px 0 0 0}code{background-color:#e8e8e8;border-radius:3px;padding:.1rem .2rem}.mce-content-body:not([dir=rtl]) blockquote{border-left:2px solid #ccc;margin-left:1.5rem;padding-left:1rem}.mce-content-body[dir=rtl] blockquote{border-right:2px solid #ccc;margin-right:1.5rem;padding-right:1rem}

Різницю між файлами не показано, бо вона завелика
+ 6 - 0
public/tinymce/skins/ui/oxide-dark/content.inline.min.css


Різницю між файлами не показано, бо вона завелика
+ 6 - 0
public/tinymce/skins/ui/oxide-dark/content.min.css


+ 7 - 0
public/tinymce/skins/ui/oxide-dark/content.mobile.min.css

@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tinymce-mobile-unfocused-selections .tinymce-mobile-unfocused-selection{background-color:green;display:inline-block;opacity:.5;position:absolute}body{-webkit-text-size-adjust:none}body img{max-width:96vw}body table img{max-width:95%}body{font-family:sans-serif}table{border-collapse:collapse}

BIN
public/tinymce/skins/ui/oxide-dark/fonts/tinymce-mobile.woff


Різницю між файлами не показано, бо вона завелика
+ 6 - 0
public/tinymce/skins/ui/oxide-dark/skin.min.css


Різницю між файлами не показано, бо вона завелика
+ 6 - 0
public/tinymce/skins/ui/oxide-dark/skin.mobile.min.css


+ 7 - 0
public/tinymce/skins/ui/oxide-dark/skin.shadowdom.min.css

@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;left:0;margin:0;overflow:hidden;-ms-scroll-chaining:none;overscroll-behavior:none;padding:0;position:fixed;top:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox.tox-tinymce.tox-fullscreen{background-color:transparent;z-index:1200}.tox-shadowhost.tox-fullscreen{z-index:1200}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}

Різницю між файлами не показано, бо вона завелика
+ 6 - 0
public/tinymce/skins/ui/oxide/content.inline.min.css


Різницю між файлами не показано, бо вона завелика
+ 6 - 0
public/tinymce/skins/ui/oxide/content.min.css


+ 7 - 0
public/tinymce/skins/ui/oxide/content.mobile.min.css

@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+.tinymce-mobile-unfocused-selections .tinymce-mobile-unfocused-selection{background-color:green;display:inline-block;opacity:.5;position:absolute}body{-webkit-text-size-adjust:none}body img{max-width:96vw}body table img{max-width:95%}body{font-family:sans-serif}table{border-collapse:collapse}

BIN
public/tinymce/skins/ui/oxide/fonts/tinymce-mobile.woff


Різницю між файлами не показано, бо вона завелика
+ 6 - 0
public/tinymce/skins/ui/oxide/skin.min.css


Різницю між файлами не показано, бо вона завелика
+ 6 - 0
public/tinymce/skins/ui/oxide/skin.mobile.min.css


+ 7 - 0
public/tinymce/skins/ui/oxide/skin.shadowdom.min.css

@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) Tiny Technologies, Inc. All rights reserved.
+ * Licensed under the LGPL or a commercial license.
+ * For LGPL see License.txt in the project root for license information.
+ * For commercial licenses see https://www.tiny.cloud/
+ */
+body.tox-dialog__disable-scroll{overflow:hidden}.tox-fullscreen{border:0;height:100%;left:0;margin:0;overflow:hidden;-ms-scroll-chaining:none;overscroll-behavior:none;padding:0;position:fixed;top:0;touch-action:pinch-zoom;width:100%}.tox.tox-tinymce.tox-fullscreen .tox-statusbar__resize-handle{display:none}.tox.tox-tinymce.tox-fullscreen{background-color:transparent;z-index:1200}.tox-shadowhost.tox-fullscreen{z-index:1200}.tox-fullscreen .tox.tox-tinymce-aux,.tox-fullscreen~.tox.tox-tinymce-aux{z-index:1201}

+ 11 - 27
src/App.vue

@@ -1,31 +1,15 @@
-<template>
-  <a-config-provider :locale="arcoLocale">
-    <router-view></router-view>
-    <global-setting />
-  </a-config-provider>
-</template>
-
-<script lang="ts" setup>
+<script setup lang="ts">
 import { computed } from 'vue';
-import { useRoute } from 'vue-router';
-import GlobalSetting from '@/components/global-setting/index.vue';
-import useLocale from '@/hooks/locale';
-import { useAppStore } from './store';
+import { ElConfigProvider } from 'element-plus';
+import { useI18n } from 'vue-i18n';
+import { getElementPlusLocale } from '@/i18n';
 
-const appStore = useAppStore();
-const route = useRoute();
-const loading = computed(() => appStore.loading);
-const { arcoLocale } = useLocale();
-onBeforeMount(() => {
-  if (!window.location.href.includes('login')) {
-    appStore.initConfig();
-  }
-});
+const { locale } = useI18n({ useScope: 'global' });
+const lang = computed(() => getElementPlusLocale(locale.value as string));
 </script>
 
-<style lang="less" scoped>
-.arco-spin {
-  width: 100%;
-  height: 100%;
-}
-</style>
+<template>
+  <el-config-provider :locale="lang">
+    <router-view />
+  </el-config-provider>
+</template>

+ 66 - 0
src/api/config.ts

@@ -0,0 +1,66 @@
+import axios from '@/utils/request';
+
+export const imageUploadUrl = `${import.meta.env.VITE_BASE_API}/backend/image-upload`;
+export const avatarUploadUrl = `${import.meta.env.VITE_BASE_API}/backend/avatar-upload`;
+export const videoUploadUrl = `${import.meta.env.VITE_BASE_API}/backend/video-upload`;
+export const audioUploadUrl = `${import.meta.env.VITE_BASE_API}/backend/audio-upload`;
+export const docUploadUrl = `${import.meta.env.VITE_BASE_API}/backend/doc-upload`;
+export const fileUploadUrl = `${import.meta.env.VITE_BASE_API}/backend/file-upload`;
+
+export const cropImage = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/image-crop', data)).data;
+export const cropAvatar = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/avatar-crop', data)).data;
+export const fetchImage = async (url: string): Promise<any> => (await axios.post('/backend/image-fetch', url, { headers: { 'Content-Type': 'text/plain' } })).data;
+
+export const queryConfigModel = async (): Promise<any> => (await axios.get('/backend/core/config/model')).data;
+export const queryConfig = async (): Promise<any> => (await axios.get('/backend/core/config')).data;
+export const queryConfigSms = async (): Promise<any> => (await axios.get('/backend/core/config/sms')).data;
+export const queryConfigEmail = async (): Promise<any> => (await axios.get('/backend/core/config/email')).data;
+export const queryUploadStorage = async (): Promise<any> => (await axios.get('/backend/core/config/upload-storage')).data;
+export const queryHtmlStorage = async (): Promise<any> => (await axios.get('/backend/core/config/html-storage')).data;
+export const queryTemplateStorage = async (): Promise<any> => (await axios.get('/backend/core/config/template-storage')).data;
+export const updateConfigBase = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/base?_method=put', data)).data;
+export const updateConfigCustoms = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/customs?_method=put', data)).data;
+export const updateConfigUpload = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/upload?_method=put', data)).data;
+export const updateConfigRegister = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/register?_method=put', data)).data;
+export const updateConfigSecurity = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/security?_method=put', data)).data;
+export const updateConfigSms = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/sms?_method=put', data)).data;
+export const sendTestSms = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/sms/send', data)).data;
+export const updateConfigEmail = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/email?_method=put', data)).data;
+export const sendTestEmail = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/email/send', data)).data;
+export const updateUploadStorage = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/upload-storage?_method=put', data)).data;
+export const updateHtmlStorage = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/html-storage?_method=put', data)).data;
+export const updateTemplateStorage = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/config/template-storage?_method=put', data)).data;
+
+export const querySiteSettings = async (): Promise<any> => (await axios.get('/backend/core/site-settings')).data;
+export const updateSiteBaseSettings = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/site-settings/base?_method=put', data)).data;
+export const updateSiteCustomsSettings = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/site-settings/customs?_method=put', data)).data;
+export const updateSiteWatermarkSettings = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/site-settings/watermark?_method=put', data)).data;
+export const updateSiteMessageBoardSettings = async (data: Record<string, any>): Promise<any> =>
+  (await axios.post('/backend/core/site-settings/message-board?_method=put', data)).data;
+export const querySiteHtmlSettings = async (): Promise<any> => (await axios.get('/backend/core/site-settings/html')).data;
+export const updateSiteHtmlSettings = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/site-settings/html?_method=put', data)).data;
+export const queryCurrentSiteThemeList = async (): Promise<any> => (await axios.get('/backend/core/site/theme')).data;
+
+export const queryModelList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/model', { params })).data;
+export const queryModel = async (id: number): Promise<any> => (await axios.get(`/backend/core/model/${id}`)).data;
+export const createModel = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/model', data)).data;
+export const updateModel = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/model?_method=put', data)).data;
+export const updateModelOrder = async (data: number[]): Promise<any> => (await axios.post('/backend/core/model/order?_method=put', data)).data;
+export const deleteModel = async (data: number[]): Promise<any> => (await axios.post('/backend/core/model?_method=delete', data)).data;
+
+export const queryDictTypeList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/dict-type', { params })).data;
+export const queryDictType = async (id: number): Promise<any> => (await axios.get(`/backend/core/dict-type/${id}`)).data;
+export const createDictType = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/dict-type', data)).data;
+export const updateDictType = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/dict-type?_method=put', data)).data;
+export const updateDictTypeOrder = async (data: number[]): Promise<any> => (await axios.post('/backend/core/dict-type/order?_method=put', data)).data;
+export const deleteDictType = async (data: number[]): Promise<any> => (await axios.post('/backend/core/dict-type?_method=delete', data)).data;
+export const dictTypeAliasExist = async (alias: string, scope: number): Promise<any> => (await axios.get('/backend/core/dict-type/alias-exist', { params: { alias, scope } })).data;
+
+export const queryBlockList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/block', { params })).data;
+export const queryBlock = async (id: number): Promise<any> => (await axios.get(`/backend/core/block/${id}`)).data;
+export const createBlock = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/block', data)).data;
+export const updateBlock = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/block?_method=put', data)).data;
+export const updateBlockOrder = async (data: number[]): Promise<any> => (await axios.post('/backend/core/block/order?_method=put', data)).data;
+export const deleteBlock = async (data: number[]): Promise<any> => (await axios.post('/backend/core/block?_method=delete', data)).data;
+export const blockAliasExist = async (alias: string, scope: number): Promise<any> => (await axios.get('/backend/core/block/alias-exist', { params: { alias, scope } })).data;
+export const blockScopeNotAllowed = async (scope: number): Promise<any> => (await axios.get('/backend/core/block/scope-not-allowed', { params: { scope } })).data;

+ 61 - 0
src/api/content.ts

@@ -0,0 +1,61 @@
+import axios from '@/utils/request';
+
+export const jodConvertDocUrl = `${import.meta.env.VITE_BASE_API}/backend/core/jod-convert/doc`;
+export const jodConvertLibraryUrl = `${import.meta.env.VITE_BASE_API}/backend/core/jod-convert/library`;
+export const queryjodConvertEnabled = async (): Promise<boolean> => (await axios.get('/backend/core/jod-convert/enabled')).data;
+
+export const queryChannelList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/channel', { params })).data;
+export const queryChannel = async (id: number): Promise<any> => (await axios.get(`/backend/core/channel/${id}`)).data;
+export const createChannel = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/channel', data)).data;
+export const updateChannel = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/channel?_method=put', data)).data;
+export const updateChannelOrder = async (data: number[]): Promise<any> => (await axios.post('/backend/core/channel/order?_method=put', data)).data;
+export const deleteChannel = async (data: number[]): Promise<any> => (await axios.post('/backend/core/channel?_method=delete', data)).data;
+export const queryChannelPermissions = async (): Promise<any> => (await axios.get('/backend/core/channel/channel-permissions')).data;
+export const queryChannelTemplates = async (): Promise<any> => (await axios.get('/backend/core/channel/channel-templates')).data;
+export const queryArticleTemplates = async (): Promise<any> => (await axios.get('/backend/core/channel/article-templates')).data;
+
+export const queryArticlePage = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/article', { params })).data;
+export const queryArticle = async (id: number): Promise<any> => (await axios.get(`/backend/core/article/${id}`)).data;
+export const createArticle = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/article', data)).data;
+export const updateArticle = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/article?_method=put', data)).data;
+export const internalPushArticle = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/article/internal-push', data)).data;
+export const externalPushArticle = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/article/external-push', data)).data;
+export const stickyArticle = async (ids: number[], sticky: number): Promise<any> => (await axios.post('/backend/core/article/sticky?_method=put', { ids, sticky })).data;
+export const deleteArticle = async (data: number[]): Promise<any> => (await axios.post('/backend/core/article/delete?_method=put', data)).data;
+export const submitArticle = async (data: number[]): Promise<any> => (await axios.post('/backend/core/article/submit?_method=put', data)).data;
+export const archiveArticle = async (data: number[]): Promise<any> => (await axios.post('/backend/core/article/archive?_method=put', data)).data;
+export const offlineArticle = async (data: number[]): Promise<any> => (await axios.post('/backend/core/article/offline?_method=put', data)).data;
+export const completelyDeleteArticle = async (data: number[]): Promise<any> => (await axios.post('/backend/core/article?_method=delete', data)).data;
+
+export const queryArticleReviewPage = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/article-review', { params })).data;
+export const passArticle = async (data: number[]): Promise<any> => (await axios.post('/backend/core/article-review/pass?_method=put', data)).data;
+export const rejectArticle = async (ids: number[], reason: string): Promise<any> => (await axios.post('/backend/core/article-review/reject?_method=put', { ids, reason })).data;
+
+export const queryBlockItemList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/block-item', { params })).data;
+export const queryBlockItem = async (id: number): Promise<any> => (await axios.get(`/backend/core/block-item/${id}`)).data;
+export const createBlockItem = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/block-item', data)).data;
+export const updateBlockItem = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/block-item?_method=put', data)).data;
+export const updateBlockItemOrder = async (data: number[]): Promise<any> => (await axios.post('/backend/core/block-item/order?_method=put', data)).data;
+export const deleteBlockItem = async (data: number[]): Promise<any> => (await axios.post('/backend/core/block-item?_method=delete', data)).data;
+
+export const queryDictList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/dict', { params })).data;
+export const queryDictListByAlias = async (alias: string, name?: string): Promise<any> => (await axios.get('/backend/core/dict/list-by-alias', { params: { alias, name } })).data;
+export const queryDict = async (id: number): Promise<any> => (await axios.get(`/backend/core/dict/${id}`)).data;
+export const createDict = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/dict', data)).data;
+export const updateDict = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/dict?_method=put', data)).data;
+export const updateDictOrder = async (data: number[]): Promise<any> => (await axios.post('/backend/core/dict/order?_method=put', data)).data;
+export const deleteDict = async (data: number[]): Promise<any> => (await axios.post('/backend/core/dict?_method=delete', data)).data;
+
+export const fulltextReindexAll = async (): Promise<any> => (await axios.post('/backend/core/generator/fulltext-reindex-all')).data;
+export const fulltextReindexSite = async (): Promise<any> => (await axios.post('/backend/core/generator/fulltext-reindex-site')).data;
+export const htmlAll = async (): Promise<any> => (await axios.post('/backend/core/generator/html-all')).data;
+export const htmlHome = async (): Promise<any> => (await axios.post('/backend/core/generator/html-home')).data;
+export const htmlChannel = async (): Promise<any> => (await axios.post('/backend/core/generator/html-channel')).data;
+export const htmlArticle = async (): Promise<any> => (await axios.post('/backend/core/generator/html-article')).data;
+
+export const queryTagPage = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/tag', { params })).data;
+export const queryTagList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/tag/list', { params })).data;
+export const queryTag = async (id: number): Promise<any> => (await axios.get(`/backend/core/tag/${id}`)).data;
+export const createTag = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/tag', data)).data;
+export const updateTag = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/tag?_method=put', data)).data;
+export const deleteTag = async (data: number[]): Promise<any> => (await axios.post('/backend/core/tag?_method=delete', data)).data;

+ 33 - 0
src/api/login.ts

@@ -0,0 +1,33 @@
+import axios from '@/utils/request';
+
+export interface LoginParam {
+  username?: string;
+  password: string;
+  browser?: boolean;
+  captchaToken?: string;
+  shortMessageId?: number;
+}
+
+export interface RefreshTokenParam {
+  refreshToken: string;
+  browser?: boolean;
+}
+
+export const accountLogin = async (data: LoginParam): Promise<any> => (await axios.post('/auth/jwt/login', data)).data;
+export const accountLogout = async (refreshToken: string): Promise<any> => (await axios.post('/auth/jwt/logout', { refreshToken })).data;
+export const accountRefreshToken = async (data: RefreshTokenParam): Promise<any> => (await axios.post('/auth/jwt/refresh-token', data)).data;
+export const queryCurrentUser = async (): Promise<any> => (await axios.get('/env/current-user')).data;
+export const queryCurrentSiteList = async (): Promise<any> => (await axios.get('/env/current-site-list')).data;
+export const queryClientPublicKey = async (): Promise<any> => (await axios.get('/env/client-public-key')).data;
+export const queryConfig = async (): Promise<any> => (await axios.get('/env/config')).data;
+export const queryCaptcha = async (): Promise<any> => (await axios.get('/captcha')).data;
+export const queryIsDisplayCaptcha = async (): Promise<any> => (await axios.get('/captcha/is-display')).data;
+export const sendMobileMessage = async (token: string, captcha: string, mobile: string, usage: number): Promise<any> =>
+  (await axios.post('/sms/mobile', { token, captcha, receiver: mobile, usage })).data;
+export const queryIsMfaLogin = async (): Promise<any> => (await axios.get('/env/is-mfa-login')).data;
+export const tryCaptcha = async (token: string, captcha: string): Promise<any> => (await axios.get('/captcha/try', { params: { token, captcha } })).data;
+export const mobileNotExist = async (mobile: string): Promise<any> => (await axios.get('/user/mobile-not-exist', { params: { mobile } })).data;
+export const updatePassword = async (data: Record<string, any>): Promise<any> => (await axios.post('/update-password?_method=put', data)).data;
+
+export const getCmsUrl = async (): Promise<any> => (await axios.get('/local/index')).data;
+export const getMinioUrl = async (): Promise<any> => (await axios.get('/local/minio')).data;

+ 11 - 0
src/api/statistics.ts

@@ -0,0 +1,11 @@
+import axios from '@/utils/request';
+
+export const queryQuestionnairePage = async (data?: Record<string, any>): Promise<any> => (await axios.post('/backend/core/questionnaire/page', data)).data;
+export const queryQuestionnaireRecordPage = async (data?: Record<string, any>): Promise<any> => (await axios.post('/backend/core/record/answerRecords', data)).data;
+export const queryQuestionnaire = async (id: number): Promise<any> => (await axios.post(`/backend/core/questionnaire/query`, { id })).data;
+export const queryQuestionnaireStatistics = async (data: Record<string, any>): Promise<any> => (await axios.post(`/backend/core/record/statistics`, data)).data;
+export const createQuestionnaire = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/questionnaire/create', data)).data;
+export const updateQuestionnaire = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/questionnaire/update', data)).data;
+export const deleteQuestionnaire = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/questionnaire/delete', data)).data;
+export const updateQuestionnaireStatus = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/questionnaire/updateStatus', data)).data;
+export const downloadQuestionnaire = async (data: Record<string, any>): Promise<any> => await axios.exportFile('/backend/core/record/download', data, 'post');

+ 45 - 0
src/api/system.ts

@@ -0,0 +1,45 @@
+import axios from '@/utils/request';
+
+export const queryCurrentSite = async (): Promise<any> => (await axios.get(`/backend/core/site/current`)).data;
+
+export const querySiteList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/site', { params })).data;
+export const querySite = async (id: number): Promise<any> => (await axios.get(`/backend/core/site/${id}`)).data;
+export const createSite = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/site', data)).data;
+export const updateSite = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/site?_method=put', data)).data;
+export const updateSiteOrder = async (data: number[]): Promise<any> => (await axios.post('/backend/core/site/order?_method=put', data)).data;
+export const deleteSite = async (data: number[]): Promise<any> => (await axios.post('/backend/core/site?_method=delete', data)).data;
+export const querySiteThemeList = async (id: number): Promise<any> => (await axios.get(`/backend/core/site/${id}/theme`)).data;
+
+export const queryAttachmentPage = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/attachment', { params })).data;
+export const queryAttachment = async (id: number): Promise<any> => (await axios.get(`/backend/core/attachment/${id}`)).data;
+export const createAttachment = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/attachment', data)).data;
+export const updateAttachment = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/attachment?_method=put', data)).data;
+export const deleteAttachment = async (data: number[]): Promise<any> => (await axios.post('/backend/core/attachment?_method=delete', data)).data;
+
+export const queryTaskPage = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/task', { params })).data;
+export const queryTask = async (id: number): Promise<any> => (await axios.get(`/backend/core/task/${id}`)).data;
+export const createTask = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/task', data)).data;
+export const updateTask = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/task?_method=put', data)).data;
+export const deleteTask = async (data: number[]): Promise<any> => (await axios.post('/backend/core/task?_method=delete', data)).data;
+
+export const queryProcessModelList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/process-model', { params })).data;
+export const queryProcessModel = async (id: string): Promise<any> => (await axios.get(`/backend/core/process-model/${id}`)).data;
+export const queryProcessModelXml = async (id: string): Promise<any> => (await axios.get(`/backend/core/process-model/xml/${id}`)).data;
+export const createProcessModel = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/process-model', data)).data;
+export const updateProcessModel = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/process-model?_method=put', data)).data;
+export const updateProcessModelXml = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/process-model/xml?_method=put', data)).data;
+export const validateProcessModelXml = async (xml: string): Promise<any> =>
+  (await axios.post('/backend/core/process-model/xml/validate', xml, { headers: { 'Content-Type': 'text/plain' } })).data;
+export const deployProcessModel = async (id: string): Promise<any> => (await axios.post(`/backend/core/process-model/deploy/${id}`)).data;
+export const deleteProcessModel = async (data: string[]): Promise<any> => (await axios.post('/backend/core/process-model?_method=delete', data)).data;
+
+export const queryProcessDefinitionList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/process-definition', { params })).data;
+export const queryProcessDefinitionXml = async (id: string): Promise<any> => (await axios.get(`/backend/core/process-definition/xml/${id}`)).data;
+export const deleteProcessDefinition = async (data: string[]): Promise<any> => (await axios.post('/backend/core/process-definition?_method=delete', data)).data;
+
+export const queryProcessInstanceList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/process-instance', { params })).data;
+export const queryProcessTaskList = async (instanceId: string): Promise<any> => (await axios.get(`/backend/core/process-instance/task/${instanceId}`)).data;
+export const deleteProcessInstance = async (data: string[]): Promise<any> => (await axios.post('/backend/core/process-instance?_method=delete', data)).data;
+
+export const queryProcessHistoryList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/process-history', { params })).data;
+export const deleteProcessHistory = async (data: string[]): Promise<any> => (await axios.post('/backend/core/process-history?_method=delete', data)).data;

+ 44 - 0
src/api/user.ts

@@ -0,0 +1,44 @@
+import axios from '@/utils/request';
+
+export const queryOrgList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/org', { params })).data;
+export const queryOrg = async (id: number): Promise<any> => (await axios.get(`/backend/core/org/${id}`)).data;
+export const createOrg = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/org', data)).data;
+export const updateOrg = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/org?_method=put', data)).data;
+export const updateOrgOrder = async (data: number[]): Promise<any> => (await axios.post('/backend/core/org/order?_method=put', data)).data;
+export const deleteOrg = async (data: number[]): Promise<any> => (await axios.post('/backend/core/org?_method=delete', data)).data;
+
+export const queryRoleList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/role', { params })).data;
+export const queryEnabledRoleList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/role/enabled', { params })).data;
+export const queryRole = async (id: number): Promise<any> => (await axios.get(`/backend/core/role/${id}`)).data;
+export const createRole = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/role', data)).data;
+export const updateRole = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/role?_method=put', data)).data;
+export const updateRolePermission = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/role/permission?_method=put', data)).data;
+export const updateRoleOrder = async (data: number[]): Promise<any> => (await axios.post('/backend/core/role/order?_method=put', data)).data;
+export const deleteRole = async (data: number[]): Promise<any> => (await axios.post('/backend/core/role?_method=delete', data)).data;
+export const roleArticlePermissions = async (roleId: number, siteId?: number): Promise<any> =>
+  (await axios.get('/backend/core/role/article-permissions', { params: { roleId, siteId } })).data;
+export const roleChannelPermissions = async (roleId: number, siteId?: number): Promise<any> =>
+  (await axios.get('/backend/core/role/channel-permissions', { params: { roleId, siteId } })).data;
+export const roleScopeNotAllowed = async (scope: number): Promise<any> => (await axios.get('/backend/core/role/scope-not-allowed', { params: { scope } })).data;
+
+export const queryGroupList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/group', { params })).data;
+export const queryGroup = async (id: number): Promise<any> => (await axios.get(`/backend/core/group/${id}`)).data;
+export const createGroup = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/group', data)).data;
+export const updateGroup = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/group?_method=put', data)).data;
+export const updateGroupPermission = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/group/permission?_method=put', data)).data;
+export const updateGroupOrder = async (data: number[]): Promise<any> => (await axios.post('/backend/core/group/order?_method=put', data)).data;
+export const deleteGroup = async (data: number[]): Promise<any> => (await axios.post('/backend/core/group?_method=delete', data)).data;
+export const groupAccessPermissions = async (groupId: number, siteId?: number): Promise<any> =>
+  (await axios.get('/backend/core/group/access-permissions', { params: { groupId, siteId } })).data;
+
+export const queryUserList = async (params?: Record<string, any>): Promise<any> => (await axios.get('/backend/core/user', { params })).data;
+export const queryUser = async (id: number): Promise<any> => (await axios.get(`/backend/core/user/${id}`)).data;
+export const createUser = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/user', data)).data;
+export const updateUser = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/user?_method=put', data)).data;
+export const updateUserPermission = async (data: Record<string, any>): Promise<any> => (await axios.post('/backend/core/user/permission?_method=put', data)).data;
+export const updateUserPassword = async (id: number, password: string): Promise<any> => (await axios.post('/backend/core/user/password?_method=put', { id, password })).data;
+export const updateUserStatus = async (ids: number[], status: number): Promise<any> => (await axios.post('/backend/core/user/status?_method=put', { ids, status })).data;
+export const deleteUser = async (data: number[]): Promise<any> => (await axios.post('/backend/core/user?_method=delete', data)).data;
+export const usernameExist = async (username?: string): Promise<any> => (await axios.get('/backend/core/user/username-exist', { params: { username } })).data;
+export const emailExist = async (email?: string): Promise<any> => (await axios.get('/backend/core/user/email-exist', { params: { email } })).data;
+export const mobileExist = async (mobile?: string): Promise<any> => (await axios.get('/backend/core/user/mobile-exist', { params: { mobile } })).data;

BIN
src/assets/404_images/404.png


BIN
src/assets/404_images/404_cloud.png


BIN
src/assets/bg.png


BIN
src/assets/home-footer.png


BIN
src/assets/home-top.png


BIN
src/assets/iApp.png


BIN
src/assets/icon.png


BIN
src/assets/layout-bg.png


BIN
src/assets/layout-top.png


+ 1 - 0
src/assets/svg/add_item.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1689320481627" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3252" id="mx_n_1689320481628" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32"><path d="M512 0c282.752 0 512 229.248 512 512s-229.248 512-512 512S0 794.752 0 512 229.248 0 512 0z m0 213.333333a42.666667 42.666667 0 0 0-42.666667 42.666667v213.333333H256l-4.992 0.298667A42.666667 42.666667 0 0 0 256 554.666667h213.333333v213.333333l0.298667 4.992A42.666667 42.666667 0 0 0 554.666667 768v-213.333333h213.333333l4.992-0.298667A42.666667 42.666667 0 0 0 768 469.333333h-213.333333V256l-0.298667-4.992A42.666667 42.666667 0 0 0 512 213.333333z" fill="#a31b50" p-id="3253"></path></svg>

+ 1 - 0
src/assets/svg/content.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><defs><style>.a,.b{fill:#a31b50;}.a{opacity:0;}</style></defs><g transform="translate(-1991 -112)"><rect class="a" width="14" height="14" transform="translate(1991 112)"/><g transform="translate(1962.735 113.437)"><path class="b" d="M36.419,10.331H31.69a1.027,1.027,0,0,1-.739-.316,1.1,1.1,0,0,1-.305-.762V1.873a1.1,1.1,0,0,1,.305-.763A1.031,1.031,0,0,1,31.69.793h7.148a1.028,1.028,0,0,1,.739.317,1.087,1.087,0,0,1,.305.762l-.016,4.881h0a.385.385,0,1,0,.771,0l.015-4.881h0A1.905,1.905,0,0,0,40.121.548,1.785,1.785,0,0,0,38.838,0H31.69a1.785,1.785,0,0,0-1.283.548,1.9,1.9,0,0,0-.53,1.325V9.253a1.9,1.9,0,0,0,.53,1.324,1.781,1.781,0,0,0,1.283.548h4.728a.4.4,0,0,0,0-.794Z"/><path class="b" d="M210.765,256.378l-1.468-1.516a2.441,2.441,0,0,0-.391-3.206,2.26,2.26,0,0,0-3.128.135,2.444,2.444,0,0,0-.131,3.229,2.263,2.263,0,0,0,3.106.4l1.468,1.516a.377.377,0,0,0,.544,0A.406.406,0,0,0,210.765,256.378Zm-3.351-1.307a1.553,1.553,0,0,1-1.51-1.28,1.6,1.6,0,0,1,.921-1.779,1.512,1.512,0,0,1,1.869.585,1.628,1.628,0,0,1-.191,2.007,1.506,1.506,0,0,1-1.089.465Zm1.155-5.561a.4.4,0,0,0,0-.8l-6.159,0a.4.4,0,0,0,0,.8l6.159,0Zm-3.849,2.382a.383.383,0,0,0,.333-.2.408.408,0,0,0,0-.4.383.383,0,0,0-.333-.2h-2.309a.4.4,0,0,0,0,.793h2.309Zm-2.309,1.588a.4.4,0,0,0,0,.794h1.54a.382.382,0,0,0,.336-.2.408.408,0,0,0,0-.4.382.382,0,0,0-.337-.2Z" transform="translate(-170.224 -245.93)"/></g></g></svg>

+ 1 - 0
src/assets/svg/icon_email.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a,.b{fill:#fff;}.a{opacity:0;}</style></defs><g transform="translate(-80 -2298)"><rect class="a" width="16" height="16" transform="translate(80 2298)"/><path class="b" d="M78.491,152.244l-13.332,0v-8.11l6.309,4.009a.669.669,0,0,0,.717,0l6.308-4.009ZM65.158,141.589l0,0,13.332,0v.971l-6.667,4.236-6.667-4.236Zm13.332-1.327H65.16a1.332,1.332,0,0,0-1.335,1.327v10.659a1.333,1.333,0,0,0,1.335,1.327h13.33a1.332,1.332,0,0,0,1.335-1.327V141.589A1.332,1.332,0,0,0,78.491,140.262Zm0,0" transform="translate(16.175 2159.082)"/></g></svg>

+ 1 - 0
src/assets/svg/icon_mob.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a,.b{fill:#fff;}.a{opacity:0;}</style></defs><g transform="translate(-69.944 -2230.944)"><rect class="a" width="16" height="16" transform="translate(69.944 2230.944)"/><path class="b" d="M76.6,74.908a.5.5,0,0,0-.69.052l-1.22,1.168a.564.564,0,0,1-.636.106,13.559,13.559,0,0,1-5.467-5.465.564.564,0,0,1,.106-.636l1.22-1.22a.5.5,0,0,0,.052-.69l-1.7-2.072a.051.051,0,0,0-.052-.052.512.512,0,0,0-.742,0l-1.434,1.433c-.69.69,1.061,3.98,4.141,7.112s6.42,4.829,7.11,4.139l1.433-1.433.052-.052a.519.519,0,0,0-.106-.742Zm-1.433-.742a1.56,1.56,0,0,1,2.123-.106l2.07,1.645a1.594,1.594,0,0,1,.265,2.229c-.052.052-.052.106-.106.106l-1.433,1.433c-1.38,1.38-5.1-.584-8.651-4.141s-5.467-7.217-4.141-8.6L66.729,65.3a1.54,1.54,0,0,1,2.229,0l.106.106,1.647,2.069a1.558,1.558,0,0,1-.106,2.123l-.9.9a12.534,12.534,0,0,0,4.512,4.512ZM73.312,67a.531.531,0,0,1,0-1.063,5.323,5.323,0,0,1,5.308,5.308.531.531,0,1,1-1.063,0A4.257,4.257,0,0,0,73.312,67Zm0,2.123a.531.531,0,0,1,0-1.063A3.193,3.193,0,0,1,76.5,71.243a.531.531,0,0,1-1.063,0A2.127,2.127,0,0,0,73.312,69.122Z" transform="translate(5.787 2166.837)"/></g></svg>

+ 1 - 0
src/assets/svg/que_del.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="29.92" height="34.195" viewBox="0 0 29.92 34.195"><defs><style>.a{fill:#a31b50;}</style></defs><path class="a" d="M215.47,177.079h25.646v27.783H215.47Zm4.274,4.274v19.235h17.1V181.353h-17.1Zm2.137,4.274h4.274v10.686h-4.274Zm8.549,0H234.7v10.686H230.43Zm-17.1-8.549h29.92v4.274h-29.92Zm8.549-6.412H234.7v4.274H221.882Z" transform="translate(-213.333 -170.667)"/></svg>

+ 1 - 0
src/assets/svg/que_edit.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="26.22" height="26.16" viewBox="0 0 26.22 26.16"><defs><style>.a{fill:#a31b50;}</style></defs><path class="a" d="M82.859,74.774l-6.151-6.209L66.359,78.817l6.151,6.209Zm3.1-3.075L79.812,65.49l-2.069,2.05,6.151,6.209ZM71.476,86.051l-6.151-6.209-1.065,7.2ZM64.03,88.736V91.65H90.25V88.736Z" transform="translate(-64.03 -65.49)"/></svg>

+ 1 - 0
src/assets/svg/que_move.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="33.879" height="33.879" viewBox="0 0 33.879 33.879"><defs><style>.a{fill:#a31b50;}</style></defs><path class="a" d="M90.619,73.68v4.84h-7.26V71.26H88.2L80.939,64l-7.26,7.26h4.84v7.26H71.26V73.68L64,80.939l2.42,2.42,4.84,4.84v-4.84h7.26v7.26H73.68l7.26,7.26,2.42-2.42,4.84-4.84h-4.84v-7.26h7.26V88.2l7.26-7.26Z" transform="translate(-64 -64)"/></svg>

+ 1 - 0
src/assets/svg/remove_item.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1689320551688" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5991" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32"><path d="M512 53.248c129.707008 3.412992 237.739008 48.299008 324.096 134.656S967.339008 382.292992 970.752 512c-3.412992 129.707008-48.299008 237.739008-134.656 324.096S641.707008 967.339008 512 970.752c-129.707008-3.412992-237.739008-48.299008-324.096-134.656S56.660992 641.707008 53.248 512c3.412992-129.707008 48.299008-237.739008 134.656-324.096S382.292992 56.660992 512 53.248zM282.624 512c0 10.923008 3.755008 20.139008 11.264 27.648 7.508992 7.508992 16.724992 11.264 27.648 11.264h380.928c10.923008 0 20.139008-3.755008 27.648-11.264 7.508992-7.508992 11.264-16.724992 11.264-27.648 0-10.923008-3.755008-20.139008-11.264-27.648-7.508992-7.508992-16.724992-11.264-27.648-11.264H321.536c-10.923008 0-20.139008 3.755008-27.648 11.264-7.508992 7.508992-11.264 16.724992-11.264 27.648z" p-id="5992" fill="#a31b50"></path></svg>

+ 1 - 0
src/assets/svg/system.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><defs><style>.a,.b{fill:#a31b50;}.a{opacity:0;}</style></defs><g transform="translate(-2019 -72)"><rect class="a" width="14" height="14" transform="translate(2019 72)"/><path class="b" d="M9.8,8.668h.817a.414.414,0,0,1,0,.828H9.8a.414.414,0,1,1,0-.828ZM4.048,7.84H6.51a.414.414,0,0,1,0,.828H4.048a.411.411,0,0,1-.414-.414.425.425,0,0,1,.414-.414Zm0-2.461H6.51a.414.414,0,1,1,0,.828H4.048a.411.411,0,0,1-.414-.414A.424.424,0,0,1,4.048,5.379Zm7.925,6.241a.533.533,0,0,1,.01,1.067.529.529,0,0,1-.529-.538A.507.507,0,0,1,11.973,11.62Zm.654-.741H11.069l-.778,1.347.778,1.355h1.558l.778-1.346ZM8.568,6.09a2.535,2.535,0,0,1,1.231-.3c1.365.1,2.048.923,2.048,2.462H11.03c0-1.125-.375-1.577-1.231-1.645-.778,0-1.231.366-1.231.8V13.59h1.558l-.7-1.212a.277.277,0,0,1,0-.3L10.5,10.2a.315.315,0,0,1,.259-.145h2.164a.315.315,0,0,1,.259.145L14.27,12.08a.3.3,0,0,1,0,.3l-1.086,1.876a.315.315,0,0,1-.259.145h-1.99v.01H2V3.744A1.638,1.638,0,0,1,3.645,2.1H6.924A1.645,1.645,0,0,1,8.569,3.745S8.568,6.09,8.568,6.09Zm-.827,7.5V3.744a.817.817,0,0,0-.817-.817H3.645a.817.817,0,0,0-.817.817v9.847Z" transform="translate(2017.999 70.9)"/></g></svg>

BIN
src/assets/szgzh.png


+ 261 - 0
src/components/DialogForm.vue

@@ -0,0 +1,261 @@
+<script setup lang="ts">
+import { computed, onMounted, PropType, ref, toRefs, watch } from 'vue';
+import { ElMessage } from 'element-plus';
+import { Plus, Delete } from '@element-plus/icons-vue';
+import { useI18n } from 'vue-i18n';
+import _ from 'lodash';
+import { perm } from '@/store/useCurrentUser';
+import { actionType, ACTION } from '@/constant/common';
+
+const CONTINUOUS_SETTINGS = 'cms_continuous_settings';
+function fetchContinuous(): Record<string, boolean> {
+  const settings = localStorage.getItem(CONTINUOUS_SETTINGS);
+  return settings ? JSON.parse(settings) : {};
+}
+function storeContinuous(settings: Record<string, boolean>) {
+  localStorage.setItem(CONTINUOUS_SETTINGS, JSON.stringify(settings));
+}
+function getContinuous(name: string) {
+  const settings = fetchContinuous();
+  return settings[name] ?? false;
+}
+function setContinuous(name: string, continuous: boolean) {
+  const settings = fetchContinuous();
+  settings[name] = continuous;
+  storeContinuous(settings);
+}
+
+const props = defineProps({
+  modelValue: { type: Boolean, required: true },
+  name: { type: String, required: true },
+  beanId: { type: [Number, String], default: null },
+  beanIds: { type: Array as PropType<string[] | number[]>, required: true },
+  values: { type: Object, required: true },
+  initValues: { type: Function as PropType<(bean?: any) => any>, required: true },
+  toValues: { type: Function as PropType<(bean: any) => any>, required: true },
+  queryBean: { type: Function as PropType<(id: any) => Promise<any>>, required: true },
+  createBean: { type: Function as PropType<(bean: any) => Promise<any>>, required: true },
+  updateBean: { type: Function as PropType<(bean: any) => Promise<any>>, required: true },
+  disableDelete: { type: Function as PropType<(bean: any) => boolean>, default: null },
+  disableEdit: { type: Function as PropType<(bean: any) => boolean>, default: null },
+  addable: { type: Boolean, default: true },
+  action: { type: String as PropType<actionType>, default: ACTION.EDIT },
+  showId: { type: Boolean, default: true },
+  perms: { type: String, default: null },
+  focus: { type: Object, default: null },
+  large: { type: Boolean, default: false },
+  labelPosition: { type: String as PropType<'top' | 'right' | 'left'>, default: 'right' },
+  labelWidth: { type: String, default: '150px' },
+});
+const emit = defineEmits({
+  'update:modelValue': null,
+  'update:values': null,
+  finished: null,
+  beanChange: null,
+  beforeSubmit: null,
+});
+
+const { name, beanId, beanIds, focus, values, action, modelValue: visible } = toRefs(props);
+const { t } = useI18n();
+const loading = ref<boolean>(false);
+const buttonLoading = ref<boolean>(false);
+const continuous = ref<boolean>(getContinuous(name.value));
+const form = ref();
+const bean = ref(props.initValues());
+const origValues = ref();
+const id = ref();
+const ids = ref<Array<any>>([]);
+const isEdit = computed(() => id.value != null && action.value === ACTION.EDIT);
+const unsaved = computed(() => {
+  // 调试 未保存
+  // if (!_.isEqual(origValues.value, values.value)) {
+  //   console.log(JSON.stringify(origValues.value));
+  //   console.log(JSON.stringify(values.value));
+  // }
+  return !loading.value && !_.isEqual(origValues.value, values.value);
+});
+const disabled = computed(() => props.disableEdit?.(bean.value) ?? false);
+const title = computed(() => `${name.value} - ${isEdit.value ? `${t(disabled.value ? ACTION.DETAIL : ACTION.EDIT)} (ID: ${id.value})` : `${t('add')}`}`);
+const loadBean = async () => {
+  loading.value = true;
+  try {
+    bean.value = id.value != null ? await props.queryBean(id.value) : props.initValues(values.value);
+    origValues.value = id.value != null ? props.toValues(bean.value) : bean.value;
+    emit('update:values', _.cloneDeep(origValues.value));
+    emit('beanChange', bean.value);
+    form.value?.resetFields();
+  } finally {
+    loading.value = false;
+  }
+};
+onMounted(() => emit('update:values', props.initValues()));
+watch(visible, () => {
+  if (visible.value) {
+    ids.value = beanIds.value;
+    if (id.value !== beanId.value) {
+      id.value = beanId.value;
+    } else {
+      loadBean();
+    }
+  }
+});
+watch(id, () => {
+  loadBean();
+});
+watch(continuous, () => setContinuous(name.value, continuous.value));
+const index = computed(() => ids.value.indexOf(id.value));
+const hasPrev = computed(() => index.value > 0);
+const hasNext = computed(() => index.value < ids.value.length - 1);
+const handlePrev = () => {
+  if (hasPrev.value) {
+    id.value = ids.value[index.value - 1];
+  }
+};
+const handleNext = () => {
+  if (hasNext.value) {
+    id.value = ids.value[index.value + 1];
+  }
+};
+// const handleAdd = () => {
+//   focus.value?.focus?.();
+//   id.value = undefined;
+// };
+// const handleCancel = () => {
+//   emit('update:modelValue', false);
+// };
+const handleSubmit = () => {
+  form.value.validate(async (valid: boolean) => {
+    if (!valid) return;
+    buttonLoading.value = true;
+    try {
+      emit('beforeSubmit', values.value);
+      if (isEdit.value) {
+        await props.updateBean(values.value);
+      } else {
+        await props.createBean(values.value);
+        // eslint-disable-next-line no-unused-expressions
+        focus.value?.focus?.();
+        emit('update:values', props.initValues(values.value));
+        form.value.resetFields();
+      }
+      ElMessage.success(t('success'));
+      if (!continuous.value) emit('update:modelValue', false);
+      emit('finished', bean.value);
+    } finally {
+      buttonLoading.value = false;
+    }
+  });
+};
+// const handleDelete = async () => {
+//   buttonLoading.value = true;
+//   try {
+//     await props.deleteBean([id.value]);
+//     if (!continuous.value) emit('update:modelValue', false);
+//     if (hasNext.value) {
+//       handleNext();
+//       ids.value.splice(index.value - 1, 1);
+//     } else if (hasPrev.value) {
+//       handlePrev();
+//       ids.value.splice(index.value + 1, 1);
+//     } else {
+//       emit('update:modelValue', false);
+//     }
+//     ElMessage.success(t('success'));
+//     emit('finished');
+//   } finally {
+//     buttonLoading.value = false;
+//   }
+// };
+const submit = (
+  executor: (values: any, payload: { isEdit: boolean; continuous: boolean; form: any; props: any; focus: any; loadBean: () => Promise<any>; emit: any }) => Promise<any>,
+) => {
+  form.value.validate(async (valid: boolean) => {
+    if (!valid) return;
+    buttonLoading.value = true;
+    try {
+      emit('beforeSubmit', values.value);
+
+      await executor(values.value, { isEdit: isEdit.value, continuous: continuous.value, form: form.value, props, focus: focus.value, loadBean, emit });
+
+      if (!continuous.value) emit('update:modelValue', false);
+      emit('finished', bean.value);
+    } finally {
+      buttonLoading.value = false;
+    }
+  });
+};
+const remove = async (
+  executor: (values: any, payload: { isEdit: boolean; continuous: boolean; form: any; props: any; focus: any; loadBean: () => Promise<any>; emit: any }) => Promise<any>,
+) => {
+  buttonLoading.value = true;
+  try {
+    await executor(values.value, { isEdit: isEdit.value, continuous: continuous.value, form: form.value, props, focus: focus.value, loadBean, emit });
+    if (!continuous.value) emit('update:modelValue', false);
+    if (hasNext.value) {
+      handleNext();
+      ids.value.splice(index.value - 1, 1);
+    } else if (hasPrev.value) {
+      handlePrev();
+      ids.value.splice(index.value + 1, 1);
+    } else {
+      emit('update:modelValue', false);
+    }
+    ElMessage.success(t('success'));
+    emit('finished');
+  } finally {
+    buttonLoading.value = false;
+  }
+};
+defineExpose({ form, submit, remove });
+</script>
+
+<template>
+  <el-dialog
+    :title="title"
+    :close-on-click-modal="!unsaved"
+    :model-value="modelValue"
+    :width="large ? '98%' : '768px'"
+    :top="large ? '16px' : '8vh'"
+    @update:model-value="(event) => $emit('update:modelValue', event)"
+    @opened="() => !isEdit && focus?.focus()"
+  >
+    <template #header>
+      {{ name }} -
+      <span v-if="isEdit">
+        {{ $t(disabled ? 'detail' : 'edit') }}
+        <span v-if="showId">(ID: {{ id }})</span>
+      </span>
+      <span v-else>{{ $t('add') }}</span>
+    </template>
+    <!-- <div v-loading="loading || buttonLoading" class="space-x-2">
+      <el-button v-if="isEdit && addable" :disabled="perm(`${perms}:create`)" type="primary" :icon="Plus" @click="handleAdd">{{ $t('add') }}</el-button>
+      <slot name="header-action" :bean="bean" :is-edit="isEdit" :disabled="disabled" :unsaved="unsaved" :disable-delete="disableDelete" :handle-delete="handleDelete">
+        <el-popconfirm v-if="isEdit" :title="$t('confirmDelete')" @confirm="() => handleDelete()">
+          <template #reference>
+            <el-button :disabled="disableDelete?.(bean) || perm(`${perms}:delete`)" :icon="Delete">{{ $t('delete') }}</el-button>
+          </template>
+        </el-popconfirm>
+      </slot>
+      <el-button-group v-if="isEdit">
+        <el-button :disabled="!hasPrev" @click="handlePrev">{{ $t('form.prev') }}</el-button>
+        <el-button :disabled="!hasNext" @click="handleNext">{{ $t('form.next') }}</el-button>
+      </el-button-group>
+      <el-button type="primary" @click="handleCancel">{{ $t('back') }}</el-button>
+      <el-tooltip :content="$t('form.continuous')" placement="top">
+        <el-switch v-model="continuous" size="small"></el-switch>
+      </el-tooltip>
+      <el-tag v-if="unsaved" type="danger">{{ $t('form.unsaved') }}</el-tag>
+      <slot name="header-status" :bean="bean" :is-edit="isEdit" :disabled="disabled"></slot>
+    </div> -->
+    <el-form ref="form" :class="['mt-5', 'pr-5']" :model="values" :disabled="disabled" :label-width="labelWidth" :label-position="labelPosition" scroll-to-error>
+      <slot :bean="bean" :is-edit="isEdit" :disabled="disabled"></slot>
+      <div v-if="!disabled" v-loading="buttonLoading" class="w-full text-center footer-action">
+        <slot name="footer-action" :bean="bean" :is-edit="isEdit" :disabled="disabled" :handle-submit="handleSubmit">
+          <el-button :disabled="perm(isEdit ? `${perms}:update` : `${perms}:create`)" type="primary" native-type="submit" @click.prevent="() => handleSubmit()">
+            {{ $t('save') }}
+          </el-button>
+        </slot>
+      </div>
+    </el-form>
+  </el-dialog>
+</template>

+ 20 - 0
src/components/LabelTip.vue

@@ -0,0 +1,20 @@
+<script setup lang="ts">
+import { QuestionFilled } from '@element-plus/icons-vue';
+
+defineProps({
+  label: { type: String, default: null },
+  tooltip: { type: String, default: null },
+  help: { type: Boolean, default: false },
+  message: { type: String, default: '' },
+  fixWidth: { type: Boolean, default: true },
+});
+</script>
+
+<template>
+  <div class="inline-flex items-center" :style="fixWidth ? { maxWidth: 'calc(100% - 16px)' } : undefined">
+    <div class="text-clip overflow-hidden whitespace-nowrap" :title="label ?? $t(message)">{{ label ?? $t(message) }}</div>
+    <el-tooltip v-if="help" :content="tooltip ?? $t(message + '.tooltip')" placement="top">
+      <el-icon class="text-base align-text-top"><QuestionFilled /></el-icon>
+    </el-tooltip>
+  </div>
+</template>

+ 19 - 0
src/components/ListMove.vue

@@ -0,0 +1,19 @@
+<script setup lang="ts">
+import { Top, Bottom, ArrowUp, ArrowDown } from '@element-plus/icons-vue';
+
+defineProps({
+  disabled: { type: Boolean, required: true },
+});
+defineEmits({
+  move: null,
+});
+</script>
+
+<template>
+  <el-button-group>
+    <el-button :disabled="disabled" :icon="Top" @click="() => $emit('move', 'top')">{{ $t('moveTop') }}</el-button>
+    <el-button :disabled="disabled" :icon="ArrowUp" @click="() => $emit('move', 'up')">{{ $t('moveUp') }}</el-button>
+    <el-button :disabled="disabled" :icon="ArrowDown" @click="() => $emit('move', 'down')">{{ $t('moveDown') }}</el-button>
+    <el-button :disabled="disabled" :icon="Bottom" @click="() => $emit('move', 'bottom')">{{ $t('moveBottom') }}</el-button>
+  </el-button-group>
+</template>

+ 57 - 0
src/components/QueryForm/QueryForm.vue

@@ -0,0 +1,57 @@
+<script setup lang="ts">
+import { useSlots, provide, computed, ref, toRefs } from 'vue';
+import { Plus, Minus, Search, Refresh } from '@element-plus/icons-vue';
+import QueryInput from './QueryInput.vue';
+
+const props = defineProps({ params: { type: Object, required: true } });
+const { params } = toRefs(props);
+const slots = useSlots();
+provide('params', params);
+defineEmits({
+  search: null,
+  reset: null,
+});
+
+const data = ref<any[]>([]);
+const inputs = computed(() => slots.default?.() ?? []);
+data.value = inputs.value.map((item) => ({ label: item.props?.label, name: item.props?.name }));
+const [first] = data.value;
+const names = ref<string[]>([first.name]);
+const remains = computed(() => data.value.filter((it) => !names.value.includes(it.name)));
+const clearParams = () => {
+  Object.keys(params.value).forEach((key) => {
+    if (!names.value.includes(key) && names.value.findIndex((item) => item.split(',').includes(key)) === -1) {
+      delete params.value[key];
+    }
+  });
+};
+const handelRow = (index: number) => {
+  if (index === 0) {
+    const [item] = remains.value;
+    names.value[names.value.length] = item.name;
+  } else {
+    names.value.splice(index, 1);
+    clearParams();
+  }
+};
+</script>
+
+<template>
+  <form class="flex">
+    <div class="space-y-1">
+      <div v-for="(name, index) in names" :key="name" class="flex">
+        <el-button :icon="index == 0 ? Plus : Minus" :disabled="index <= 0 && remains.length <= 0" circle @click="() => handelRow(index)"></el-button>
+        <el-select v-model="names[index]" class="w-36" @change="() => clearParams()">
+          <el-option v-for="item in data.filter((it) => it.name === names[index] || remains.includes(it))" :key="item.name" :label="item.label" :value="item.name"></el-option>
+        </el-select>
+        <query-input :inputs="inputs" :name="names[index]"></query-input>
+      </div>
+    </div>
+    <div>
+      <el-button-group class="ml-2">
+        <el-button native-type="submit" :icon="Search" @click.prevent="() => $emit('search')">{{ $t('search') }}</el-button>
+        <el-button :icon="Refresh" @click="() => $emit('reset')">{{ $t('reset') }}</el-button>
+      </el-button-group>
+    </div>
+  </form>
+</template>

+ 12 - 0
src/components/QueryForm/QueryInput.vue

@@ -0,0 +1,12 @@
+<script lang="ts">
+import { defineComponent, toRefs } from 'vue';
+
+export default defineComponent({
+  name: 'QueryInput',
+  props: { inputs: { type: Array, required: true }, name: { type: String, required: true } },
+  setup(props) {
+    const { inputs, name } = toRefs(props);
+    return () => inputs.value.find((item: any) => item.props.name === name.value);
+  },
+});
+</script>

+ 56 - 0
src/components/QueryForm/QueryItem.vue

@@ -0,0 +1,56 @@
+<script setup lang="ts">
+import { inject, PropType, ref, toRefs } from 'vue';
+
+const props = defineProps({
+  label: { type: String, required: true },
+  name: { type: String, required: true },
+  // 'string' | 'date' | 'datetime' | 'number'
+  type: { type: String, default: null },
+  options: { type: Object as PropType<Array<{ label: string; value: string | number }>>, default: null },
+});
+const params = inject<any>('params');
+const { name } = toRefs(props);
+const [firstName, secondName] = name.value.split(',');
+const first = ref<string>(firstName);
+const second = ref<string>(secondName);
+</script>
+
+<template>
+  <slot>
+    <div v-if="type === 'number'" class="inline-block">
+      <el-input-number v-model="params[first]" :placeholder="$t('begin.number')" class="w-48"></el-input-number>
+      <el-input-number v-model="params[second]" :placeholder="$t('end.number')" class="w-48"></el-input-number>
+    </div>
+    <el-date-picker
+      v-else-if="type === 'date'"
+      v-model="params[name]"
+      type="daterange"
+      :start-placeholder="$t('begin.date')"
+      :end-placeholder="$t('end.date')"
+      class="w-96"
+    ></el-date-picker>
+    <el-date-picker
+      v-else-if="type === 'datetime'"
+      v-model="params[name]"
+      type="datetimerange"
+      :start-placeholder="$t('begin.date')"
+      :end-placeholder="$t('end.date')"
+      class="w-96"
+    >
+    </el-date-picker>
+    <!--
+    <div v-else-if="type === 'date'" class="inline-block">
+      <el-date-picker v-model="params[first]" type="date" :placeholder="$t('begin.date')" class="w-48"></el-date-picker>
+      <el-date-picker v-model="params[second]" type="date" :placeholder="$t('end.date')" class="w-48"></el-date-picker>
+    </div>
+    <div v-else-if="type === 'datetime'" class="inline-block">
+      <el-date-picker v-model="params[first]" type="datetime" class="w-48"></el-date-picker>
+      <el-date-picker v-model="params[second]" type="datetime" class="w-48"></el-date-picker>
+    </div>
+    -->
+    <el-select v-else-if="options" v-model="params[name]" multiple class="w-96">
+      <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value"></el-option>
+    </el-select>
+    <el-input v-else v-model="params[name]" class="w-96"></el-input>
+  </slot>
+</template>

+ 2 - 0
src/components/QueryForm/index.ts

@@ -0,0 +1,2 @@
+export { default as QueryForm } from './QueryForm.vue';
+export { default as QueryItem } from './QueryItem.vue';

+ 45 - 0
src/components/TableList/ColumnList.vue

@@ -0,0 +1,45 @@
+<script lang="ts">
+import { computed, defineComponent, toRefs } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { ColumnState, getColumnSettings, setColumnOrigins } from './useColumns';
+
+export default defineComponent({
+  name: 'ColumnList',
+  props: { name: { type: String, required: true } },
+  setup(props, { slots }) {
+    const { name } = toRefs(props);
+    const { t } = useI18n();
+    const slotColumns = slots.default?.() ?? [];
+    // 获取栏目名称
+    const getColumnTitle = (columnProps: any) => {
+      // 如果是checkbox列,则名称为“选择框”
+      if (columnProps?.type === 'selection') return t('table.selection');
+      return columnProps?.label;
+    };
+    // 获取el-table-column的名称、是否显示
+    const origins: ColumnState[] = slotColumns.map((column) => ({ title: getColumnTitle(column.props), display: column.props?.display !== 'none' }));
+    setColumnOrigins(name.value, origins);
+
+    const settings = getColumnSettings(name.value);
+    const columns = computed(() =>
+      slotColumns
+        .filter((column) => {
+          const matched = settings.value.find((item) => getColumnTitle(column.props) === item.title);
+          return !matched || matched.display;
+        })
+        .map((column) => ({ ...column, key: getColumnTitle(column.props) }))
+        .sort((a, b) => {
+          let indexA = settings.value.findIndex((item) => item.title === getColumnTitle(a));
+          if (indexA < 0) indexA = slotColumns.findIndex((item) => getColumnTitle(item) === getColumnTitle(a));
+          let indexB = settings.value.findIndex((item) => item.title === getColumnTitle(b));
+          if (indexB < 0) indexB = slotColumns.findIndex((item) => getColumnTitle(item) === getColumnTitle(b));
+          return indexA - indexB;
+        }),
+    );
+    return { columns };
+  },
+  render() {
+    return this.columns;
+  },
+});
+</script>

+ 31 - 0
src/components/TableList/ColumnSetting.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+import { toRefs, watch, ref } from 'vue';
+import { Setting } from '@element-plus/icons-vue';
+import { getColumnOrigins, getColumnSettings, mergeColumns, storeColumnSettings } from './useColumns';
+
+const props = defineProps({ name: { type: String, required: true } });
+const { name } = toRefs(props);
+const settings = getColumnSettings(name.value);
+const origins = getColumnOrigins(name.value);
+const visible = ref<boolean>(false);
+watch(settings, () => storeColumnSettings(), { deep: true });
+const resetColumns = () => {
+  settings.value = mergeColumns([], origins.value);
+};
+</script>
+
+<template>
+  <div class="inline-flex align-middle">
+    <el-tooltip :content="$t('table.columnsSetting')" placement="top">
+      <el-icon class="cursor-pointer text-lg text-gray-regular" @click="() => (visible = true)"><Setting /></el-icon>
+    </el-tooltip>
+    <el-drawer v-model="visible" :title="$t('table.columnsSetting')" :size="375">
+      <el-button class="mb-1" round @click="resetColumns">{{ $t('reset') }}</el-button>
+      <ul>
+        <li v-for="(column, index) in settings" :key="column.title" :divided="index === 0">
+          <el-checkbox v-model="column.display">{{ column.title }}</el-checkbox>
+        </li>
+      </ul>
+    </el-drawer>
+  </div>
+</template>

+ 2 - 0
src/components/TableList/index.ts

@@ -0,0 +1,2 @@
+export { default as ColumnSetting } from './ColumnSetting.vue';
+export { default as ColumnList } from './ColumnList.vue';

+ 55 - 0
src/components/TableList/useColumns.ts

@@ -0,0 +1,55 @@
+import { reactive, toRef } from 'vue';
+
+export interface ColumnState {
+  title: string;
+  display: boolean;
+}
+
+const COLUMN_SETTINGS = 'cms_column_settings';
+
+function fetchColumnSettings(): Record<string, ColumnState[]> {
+  const settings = localStorage.getItem(COLUMN_SETTINGS);
+  return settings ? JSON.parse(settings) : {};
+}
+
+const originStore: Record<string, ColumnState[]> = reactive({});
+const settingStore: Record<string, ColumnState[]> = reactive(fetchColumnSettings());
+
+export function storeColumnSettings() {
+  localStorage.setItem(COLUMN_SETTINGS, JSON.stringify(settingStore));
+}
+export const getColumnOrigins = (name: string) => {
+  if (!originStore[name]) originStore[name] = [];
+  return toRef(originStore, name);
+};
+export const mergeColumns = (settings: ColumnState[], origins: ColumnState[]) => {
+  // 去除不存在的列
+  for (let i = 0, len = settings.length; i < len; ) {
+    if (origins.findIndex((column) => column.title === settings[i].title) === -1) {
+      settings.splice(i, 1);
+      len -= 1;
+    } else {
+      i += 1;
+    }
+  }
+  // 增加未记录的列
+  origins.forEach((column) => {
+    if (settings.findIndex((item) => item.title === column.title) === -1) {
+      settings.push({ ...column });
+    }
+  });
+  return settings;
+};
+export const setColumnOrigins = (name: string, origins: ColumnState[]) => {
+  originStore[name] = origins;
+  if (!settingStore[name]) settingStore[name] = [];
+  const settings = settingStore[name];
+  mergeColumns(settings, origins);
+};
+export const getColumnSettings = (name: string) => {
+  if (!settingStore[name]) settingStore[name] = [];
+  return toRef(settingStore, name);
+};
+// export const setColumnSettings = (name: string, settings: ColumnState[]) => {
+//   settingStore[name] = settings;
+// };

+ 377 - 0
src/components/Tinymce/Tinymce.vue

@@ -0,0 +1,377 @@
+<script lang="ts">
+import { defineComponent, ref, toRefs, watch, onMounted, onBeforeUnmount, onActivated, onDeactivated, PropType } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useFormItem } from 'element-plus';
+import { getAuthHeaders } from '@/utils/auth';
+import { baseSettings, uploadSettings, securitySettings } from '@/store/useConfig';
+import { imageUploadUrl, fileUploadUrl, videoUploadUrl, fetchImage } from '@/api/config';
+
+// 参考:https://www.tiny.cloud/docs/advanced/usage-with-module-loaders/webpack/webpack_es6_npm/
+// 参考:https://github.com/tinymce/tinymce-vue/blob/main/src/main/ts/components/Editor.ts
+// Import TinyMCE
+import tinymce from 'tinymce';
+// Default icons are required for TinyMCE 5.3 or above
+import 'tinymce/icons/default';
+// A theme is also required
+import 'tinymce/themes/silver';
+// Any plugins you want to use has to be imported
+import 'tinymce/plugins/advlist';
+// import 'tinymce/plugins/anchor';
+// import 'tinymce/plugins/autolink';
+import 'tinymce/plugins/autoresize';
+import 'tinymce/plugins/autosave';
+import 'tinymce/plugins/charmap';
+import 'tinymce/plugins/code';
+import 'tinymce/plugins/codesample';
+import 'tinymce/plugins/directionality';
+import 'tinymce/plugins/fullscreen';
+import 'tinymce/plugins/hr';
+// import 'tinymce/plugins/insertdatetime';
+import 'tinymce/plugins/image';
+import 'tinymce/plugins/imagetools';
+import 'tinymce/plugins/link';
+import 'tinymce/plugins/lists';
+import 'tinymce/plugins/media';
+// import 'tinymce/plugins/nonbreaking';
+// import 'tinymce/plugins/noneditable';
+import 'tinymce/plugins/pagebreak';
+import 'tinymce/plugins/paste';
+// import 'tinymce/plugins/preview';
+// import 'tinymce/plugins/print';
+import 'tinymce/plugins/quickbars';
+// import 'tinymce/plugins/save';
+import 'tinymce/plugins/searchreplace';
+// import 'tinymce/plugins/spellchecker';
+// import 'tinymce/plugins/tabfocus';
+import 'tinymce/plugins/table';
+// import 'tinymce/plugins/template';
+// import 'tinymce/plugins/textpattern';
+// import 'tinymce/plugins/toc';
+import 'tinymce/plugins/visualblocks';
+import 'tinymce/plugins/visualchars';
+// import 'tinymce/plugins/wordcount';
+
+import { isTextarea, uuid, initEditor } from './utils';
+
+export default defineComponent({
+  name: 'TinymceEditor',
+  props: {
+    id: { type: String, default: null },
+    modelValue: { type: String, default: '' },
+    disabled: { type: Boolean, default: false },
+    inline: { type: Boolean },
+    init: { type: Object, default: null },
+    modelEvents: { type: [String, Array], default: null },
+    plugins: { type: [String, Array] as PropType<string | string[]>, default: null },
+    toolbar: { type: [String, Array], default: null },
+    outputFormat: {
+      type: String as PropType<'html' | 'text'>,
+      default: 'html',
+      validator: (prop: string) => prop === 'html' || prop === 'text',
+    },
+  },
+  emits: ['update:modelValue', 'input', 'change', 'blur'],
+  setup(props, ctx) {
+    const { disabled, modelValue } = toRefs(props);
+    const { t } = useI18n();
+    const element = ref();
+    let vueEditor: any = null;
+    const elementId: string = props.id || uuid('tiny-vue');
+    const inlineEditor: boolean = (props.init && props.init.inline) || props.inline;
+    let mounting = true;
+    const { formItem } = useFormItem();
+
+    const initWrapper = (): void => {
+      let publicPath = import.meta.env.VITE_PUBLIC_PATH;
+      if (publicPath.endsWith('/')) {
+        publicPath = publicPath.substring(0, publicPath.length - 1);
+      }
+      const finalInit = {
+        language_url: `${publicPath}/tinymce/langs/zh_CN.js`,
+        language: 'zh_CN',
+        skin: 'oxide',
+        skin_url: `${publicPath}/tinymce/skins/ui/oxide`,
+        // 必须添加 '/tinymce/skins/content/default/content.min.css'。否则 fontselect 默认不显示“系统字体”。
+        content_css: [`${publicPath}/tinymce/skins/ui/oxide/content.min.css`, `${publicPath}/tinymce/skins/content/default/content.min.css`],
+        // 设置编辑器默认字体
+        content_style: 'body { font-size: 14px; }',
+        menubar: false,
+        plugins:
+          'advlist autoresize autosave charmap code codesample directionality fullscreen hr image imagetools lists link media pagebreak paste quickbars ' +
+          'searchreplace table visualblocks visualchars',
+        toolbar:
+          'fullscreen code | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | selectall removeformat pastetext | ' +
+          'quickimage media | blockquote codesample table | bullist numlist | outdent indent lineheight | forecolor backcolor | fontselect fontsizeselect formatselect | ' +
+          'superscript subscript charmap | hr | ltr rtl | visualblocks visualchars | restoredraft undo redo | searchreplace',
+        font_formats:
+          '宋体=SimSun; 微软雅黑=Microsoft YaHei; 楷体=SimKai,KaiTi; 黑体=SimHei; 隶书=SimLi,LiSu; Andale Mono=andale mono,times;Arial=arial,helvetica,sans-serif;' +
+          'Arial Black=arial black,avant garde;Comic Sans MS=comic sans ms,sans-serif;Helvetica=helvetica;Impact=impact,chicago;Times New Roman=times new roman,times',
+        quickbars_selection_toolbar: 'bold italic | h2 h3 blockquote | link',
+        quickbars_insert_toolbar: false,
+        paste_data_images: true,
+        image_uploadtab: false,
+        image_advtab: true,
+        image_caption: true,
+        images_file_types: uploadSettings.imageTypes,
+        min_height: 300,
+        max_height: 500,
+        convert_urls: false,
+        autosave_ask_before_unload: false,
+        ...props.init,
+        images_upload_handler(blobInfo: any, success: any, failure: any, progress: any) {
+          const fileSizeLimitByte = uploadSettings.imageLimitByte;
+          if (fileSizeLimitByte > 0 && blobInfo.blob().size > fileSizeLimitByte) {
+            failure(`文件大小不能超过 ${fileSizeLimitByte / 1024 / 1024} MB`, { remove: true });
+            return;
+          }
+          const xhr = new XMLHttpRequest();
+          xhr.open('POST', imageUploadUrl);
+
+          xhr.upload.onprogress = (e) => {
+            console.log(e);
+            progress((e.loaded / e.total) * 100);
+          };
+
+          xhr.onload = () => {
+            if (xhr.status === 403) {
+              failure(`HTTP Error: ${xhr.status}`, { remove: true });
+              return;
+            }
+
+            if (xhr.status < 200 || xhr.status >= 300) {
+              failure(`HTTP Error: ${xhr.status}`, { remove: true });
+              return;
+            }
+
+            const json = JSON.parse(xhr.responseText);
+
+            if (!json || typeof json.url !== 'string') {
+              failure(`Invalid JSON: ${xhr.responseText}`, { remove: true });
+              return;
+            }
+            success(json.url);
+          };
+
+          xhr.onerror = () => {
+            failure(`Image upload failed due to a XHR Transport error. Code: ${xhr.status}`, { remove: true });
+          };
+
+          const formData = new FormData();
+          formData.append('file', blobInfo.blob(), blobInfo.filename());
+
+          Object.entries(getAuthHeaders()).forEach(([key, value]: any) => xhr.setRequestHeader(key, value));
+          xhr.send(formData);
+        },
+
+        file_picker_callback(callback: any, val: any, meta: any) {
+          const input = document.createElement('input');
+          input.setAttribute('type', 'file');
+
+          let fileSizeLimtByte = 0;
+          let uploadUrl: string;
+          if (meta.filetype === 'image') {
+            fileSizeLimtByte = uploadSettings.imageLimitByte;
+            input.setAttribute('accept', uploadSettings.imageInputAccept);
+            uploadUrl = imageUploadUrl;
+            // input.setAttribute('accept', 'image/*');
+          } else if (meta.filetype === 'media') {
+            fileSizeLimtByte = uploadSettings.videoLimitByte;
+            input.setAttribute('accept', uploadSettings.videoInputAccept);
+            uploadUrl = videoUploadUrl;
+            // input.setAttribute('accept', 'video/*');
+          } else {
+            fileSizeLimtByte = uploadSettings.fileLimitByte;
+            input.setAttribute('accept', uploadSettings.fileInputAccept);
+            uploadUrl = fileUploadUrl;
+          }
+
+          /*
+            Note: In modern browsers input[type="file"] is functional without
+            even adding it to the DOM, but that might not be the case in some older
+            or quirky browsers like IE, so you might want to add it to the DOM
+            just in case, and visually hide it. And do not forget do remove it
+            once you do not need it anymore.
+          */
+
+          input.onchange = (event: Event) => {
+            const { files } = event.target as HTMLInputElement;
+            const file = files?.item(0);
+            if (!file) return;
+            if (fileSizeLimtByte > 0 && file.size > fileSizeLimtByte) {
+              tinymce.activeEditor.windowManager.alert(`文件大小不能超过 ${fileSizeLimtByte / 1024 / 1024} MB`);
+              return;
+            }
+            const xhr = new XMLHttpRequest();
+            xhr.open('POST', uploadUrl);
+
+            // xhr.upload.onprogress = (e) => {
+            //   progress((e.loaded / e.total) * 100);
+            // };
+
+            xhr.onload = () => {
+              if (xhr.status === 403) {
+                tinymce.activeEditor.windowManager.alert(`HTTP Error: ${xhr.status}`);
+                return;
+              }
+
+              if (xhr.status < 200 || xhr.status >= 300) {
+                tinymce.activeEditor.windowManager.alert(`HTTP Error: ${xhr.status}`);
+                return;
+              }
+
+              const json = JSON.parse(xhr.responseText);
+
+              if (!json || typeof json.url !== 'string') {
+                tinymce.activeEditor.windowManager.alert(`Invalid JSON: ${xhr.responseText}`);
+                return;
+              }
+
+              if (meta.filetype === 'image') {
+                callback(json.url, { alt: '' });
+              } else if (meta.filetype === 'media') {
+                callback(json.url, { source2: '' });
+                // callback('movie.mp4', { source2: 'alt.ogg', poster: 'image.jpg' });
+              } else {
+                callback(json.url, { text: json.name });
+              }
+            };
+
+            xhr.onerror = () => {
+              tinymce.activeEditor.windowManager.alert(`Image upload failed due to a XHR Transport error. Code: ${xhr.status}`);
+            };
+
+            const formData = new FormData();
+            formData.append('file', file, file.name);
+
+            Object.entries(getAuthHeaders()).forEach(([key, value]: any) => xhr.setRequestHeader(key, value));
+            xhr.send(formData);
+          };
+
+          input.click();
+        },
+
+        readonly: props.disabled,
+        selector: `#${elementId}`,
+        // plugins: mergePlugins(props.init && props.init.plugins, props.plugins),
+        // toolbar: props.toolbar || (props.init && props.init.toolbar),
+        inline: inlineEditor,
+        setup: (editor: any) => {
+          vueEditor = editor;
+          editor.on('init', (e: Event) => initEditor(e, props, ctx, editor, modelValue, formItem));
+          if (props.init && typeof props.init.setup === 'function') {
+            props.init.setup(editor);
+          }
+
+          const replaceString = (content: string, search: string, replace: string): string => {
+            let index = 0;
+            do {
+              index = content.indexOf(search, index);
+              if (index !== -1) {
+                content = content.substring(0, index) + replace + content.substring(index + search.length);
+                index += replace.length - search.length + 1;
+              }
+            } while (index !== -1);
+            return content;
+          };
+          const transparentSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
+          const replaceImageUrl = (content: string, targetUrl: string, replacementUrl: string): string => {
+            const replacementString = `src="${replacementUrl}"${replacementUrl === transparentSrc ? ' data-mce-placeholder="1"' : ''}`;
+            content = replaceString(content, `src="${targetUrl}"`, replacementString);
+            content = replaceString(content, 'data-mce-src="' + targetUrl + '"', 'data-mce-src="' + replacementUrl + '"');
+            return content;
+          };
+          const replaceUrlInUndoStack = (targetUrl: string, replacementUrl: string) => {
+            editor.undoManager.data.forEach((level: any) => {
+              if (level.type === 'fragmented') {
+                level.fragments = level.fragments.map((fragment: any) => replaceImageUrl(fragment, targetUrl, replacementUrl));
+              } else {
+                level.content = replaceImageUrl(level.content, targetUrl, replacementUrl);
+              }
+            });
+          };
+          editor.on('SetContent', ({ content, format, paste }: { content: string; format?: string; paste?: boolean; selection?: boolean }) => {
+            if (format === 'html' && paste && content.includes('src="')) {
+              const images = Array.from(editor.getBody().getElementsByTagName('img')).filter((img: any) => {
+                const src = img.src;
+                if (src.startsWith(baseSettings.uploadUrlPrefix)) {
+                  return false;
+                }
+                if (img.hasAttribute('data-mce-bogus')) {
+                  return false;
+                }
+                if (img.hasAttribute('data-mce-placeholder')) {
+                  return false;
+                }
+                if (!src || src === transparentSrc) {
+                  return false;
+                }
+                if (src.indexOf('blob:') === 0) {
+                  return false;
+                }
+                if (src.indexOf('data:') === 0) {
+                  return false;
+                }
+                const host = new URL(src).host;
+                for (let domain of securitySettings.ssrfList) {
+                  if (domain === '*' || host === domain || host.endsWith('.' + domain)) {
+                    return true;
+                  }
+                }
+                return false;
+              });
+              images.forEach(async (image: any) => {
+                const data = await fetchImage(image.src);
+                if (data.code === -1) {
+                  console.warn(data.message);
+                  return;
+                }
+                const resultUri = data.result.url;
+                const src = editor.convertURL(resultUri, 'src');
+                replaceUrlInUndoStack(image.src, resultUri);
+                editor.$(image).attr({
+                  src: resultUri,
+                  'data-mce-src': src,
+                });
+              });
+            }
+          });
+        },
+        branding: false,
+      };
+      if (isTextarea(element.value)) {
+        element.value.style.visibility = '';
+      }
+      tinymce.init({ toolbar_mode: 'sliding', ...finalInit });
+      mounting = false;
+    };
+    watch(disabled, () => {
+      if (vueEditor != null) {
+        vueEditor.setMode(disabled.value ? 'readonly' : 'design');
+      }
+    });
+    onMounted(async () => {
+      initWrapper();
+    });
+    onBeforeUnmount(() => {
+      tinymce.remove(vueEditor);
+    });
+    if (!inlineEditor) {
+      onActivated(() => {
+        if (!mounting) {
+          initWrapper();
+        }
+      });
+      onDeactivated(() => {
+        tinymce.remove(vueEditor);
+      });
+    }
+    return { element, elementId };
+  },
+});
+</script>
+
+<template>
+  <div>
+    <textarea :id="elementId" ref="element"></textarea>
+  </div>
+</template>

+ 2 - 0
src/components/Tinymce/index.ts

@@ -0,0 +1,2 @@
+import Tinymce from './Tinymce.vue';
+export default Tinymce;

+ 139 - 0
src/components/Tinymce/utils.ts

@@ -0,0 +1,139 @@
+import { Ref, watch, SetupContext } from 'vue';
+
+const validEvents = [
+  'onActivate',
+  'onAddUndo',
+  'onBeforeAddUndo',
+  'onBeforeExecCommand',
+  'onBeforeGetContent',
+  'onBeforeRenderUI',
+  'onBeforeSetContent',
+  'onBeforePaste',
+  'onBlur',
+  'onChange',
+  'onClearUndos',
+  'onClick',
+  'onContextMenu',
+  'onCopy',
+  'onCut',
+  'onDblclick',
+  'onDeactivate',
+  'onDirty',
+  'onDrag',
+  'onDragDrop',
+  'onDragEnd',
+  'onDragGesture',
+  'onDragOver',
+  'onDrop',
+  'onExecCommand',
+  'onFocus',
+  'onFocusIn',
+  'onFocusOut',
+  'onGetContent',
+  'onHide',
+  'onInit',
+  'onKeyDown',
+  'onKeyPress',
+  'onKeyUp',
+  'onLoadContent',
+  'onMouseDown',
+  'onMouseEnter',
+  'onMouseLeave',
+  'onMouseMove',
+  'onMouseOut',
+  'onMouseOver',
+  'onMouseUp',
+  'onNodeChange',
+  'onObjectResizeStart',
+  'onObjectResized',
+  'onObjectSelected',
+  'onPaste',
+  'onPostProcess',
+  'onPostRender',
+  'onPreProcess',
+  'onProgressState',
+  'onRedo',
+  'onRemove',
+  'onReset',
+  'onSaveContent',
+  'onSelectionChange',
+  'onSetAttrib',
+  'onSetContent',
+  'onShow',
+  'onSubmit',
+  'onUndo',
+  'onVisualAid',
+];
+
+const isValidKey = (key: string): boolean => validEvents.map((event) => event.toLowerCase()).indexOf(key.toLowerCase()) !== -1;
+
+const bindHandlers = (initEvent: Event, listeners: any, editor: any): void => {
+  Object.keys(listeners)
+    .filter(isValidKey)
+    .forEach((key: string) => {
+      const handler = listeners[key];
+      if (typeof handler === 'function') {
+        if (key === 'onInit') {
+          handler(initEvent, editor);
+        } else {
+          editor.on(key.substring(2), (e: any) => handler(e, editor));
+        }
+      }
+    });
+};
+
+const bindModelHandlers = (props: any, ctx: SetupContext, editor: any, modelValue: Ref<string>, formItem: any): void => {
+  const modelEvents = props.modelEvents ? props.modelEvents : null;
+  const normalizedEvents = Array.isArray(modelEvents) ? modelEvents.join(' ') : modelEvents;
+
+  watch(modelValue, (val: string, prevVal: string) => {
+    if (editor && typeof val === 'string' && val !== prevVal && val !== editor.getContent({ format: props.outputFormat })) {
+      editor.setContent(val);
+    }
+  });
+
+  // 要加上 paste 事件,否则首次粘贴时内容会为空。也可使用'change keyup undo redo'
+  editor.on(normalizedEvents ?? 'change input paste undo redo', () => {
+    const content = editor.getContent({ format: props.outputFormat });
+    ctx.emit('update:modelValue', content);
+    ctx.emit('input', content);
+    ctx.emit('change', content);
+    formItem?.validate?.('change').catch((err: any) => {
+      if (import.meta.env.MODE !== 'production') {
+        console.warn(err);
+      }
+    });
+  });
+
+  editor.on('blur', (e: any) => {
+    ctx.emit('blur', e);
+  });
+};
+
+const initEditor = (initEvent: Event, props: any, ctx: any, editor: any, modelValue: Ref<string>, formItem: any): void => {
+  editor.setContent(modelValue.value ?? '');
+  bindModelHandlers(props, ctx, editor, modelValue, formItem);
+  bindHandlers(initEvent, ctx.attrs, editor);
+};
+
+let unique = 0;
+const uuid = (prefix: string): string => {
+  const time = Date.now();
+  const random = Math.floor(Math.random() * 1000000000);
+  unique += 1;
+  return `${prefix}_${random + unique}${String(time)}`;
+};
+
+const isTextarea = (element: Element | null): element is HTMLTextAreaElement => element !== null && element.tagName.toLowerCase() === 'textarea';
+
+const normalizePluginArray = (plugins?: string | string[]): string[] => {
+  if (typeof plugins === 'undefined' || plugins === '') {
+    return [];
+  }
+
+  return Array.isArray(plugins) ? plugins : plugins.split(' ');
+};
+
+const mergePlugins = (initPlugins: string | string[], inputPlugins?: string | string[]): string[] => normalizePluginArray(initPlugins).concat(normalizePluginArray(inputPlugins));
+
+export { bindHandlers, bindModelHandlers, initEditor, isValidKey, uuid, isTextarea, mergePlugins };

+ 167 - 0
src/components/TuiEditor/TuiEditor.vue

@@ -0,0 +1,167 @@
+<script lang="ts">
+const editorEvents = ['load', 'change', 'caretChange', 'focus', 'blur', 'keydown', 'keyup', 'beforePreviewRender', 'beforeConvertWysiwygToMarkdown'];
+export default { name: 'TuiEditor' };
+</script>
+
+<script setup lang="ts">
+import { onMounted, ref, toRefs, watch, PropType, onUnmounted, nextTick } from 'vue';
+import { useFormItem } from 'element-plus';
+import { vOnClickOutside } from '@vueuse/components';
+import { decodeHTML } from 'entities';
+import Editor, { EditorType, PreviewStyle, EditorOptions } from '@toast-ui/editor';
+import chart from '@toast-ui/editor-plugin-chart';
+import codeSyntaxHighlight from '@toast-ui/editor-plugin-code-syntax-highlight';
+import tableMergedCell from '@toast-ui/editor-plugin-table-merged-cell';
+import uml from '@toast-ui/editor-plugin-uml';
+import Prism from 'prismjs';
+import { addImageBlobHook, toggleFullScreen, clickOutside } from './utils';
+import '@toast-ui/editor/dist/i18n/zh-cn';
+import '@toast-ui/editor/dist/i18n/zh-tw';
+import '@toast-ui/editor/dist/toastui-editor.css';
+import '@toast-ui/chart/dist/toastui-chart.css';
+import 'prismjs/themes/prism.css';
+import 'prismjs/components/prism-clojure.js';
+import '@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css';
+import '@toast-ui/editor-plugin-table-merged-cell/dist/toastui-editor-plugin-table-merged-cell.css';
+
+const props = defineProps({
+  modelValue: { type: String, default: '' },
+  html: { type: String, default: '' },
+  initialEditType: { type: String as PropType<EditorType>, default: 'markdown' },
+  height: { type: String, default: '300px' },
+  previewStyle: { type: String as PropType<PreviewStyle>, default: 'tab' },
+  language: { type: String, default: 'en' },
+  options: { type: Object, default: null },
+});
+const emit = defineEmits([...editorEvents, 'update:modelValue', 'update:html', 'different']);
+
+const { modelValue, html, initialEditType, height, previewStyle, language, options } = toRefs(props);
+const toastuiEditor = ref();
+let editor: Editor;
+const { formItem } = useFormItem();
+
+watch(previewStyle, () => {
+  editor.changePreviewStyle(previewStyle.value);
+});
+watch(height, () => {
+  editor.setHeight(height.value);
+});
+
+const eventOptions: any = {};
+
+// 内容为空时,默认生成以下HTML,应作为空串处理
+const emptyHtml = '<p><br class="ProseMirror-trailingBreak"></p>';
+
+editorEvents.forEach((event) => {
+  eventOptions[event] = (...args: any[]) => {
+    if (event === 'change') {
+      const newHtml = editor.getHTML();
+      if (newHtml !== html.value) {
+        emit('update:html', newHtml !== emptyHtml ? newHtml : '');
+      }
+      const newMarkdown = editor.getMarkdown();
+      if (newMarkdown !== modelValue.value) {
+        emit('update:modelValue', newMarkdown);
+      }
+      formItem?.validate?.('change').catch((err: any) => {
+        if (import.meta.env.MODE !== 'production') {
+          console.warn(err);
+        }
+      });
+    }
+    emit(event, ...args);
+  };
+});
+
+const createFullscreenButton = () => {
+  const button = document.createElement('button');
+  button.type = 'button';
+  button.className = 'toastui-editor-toolbar-icons text-xl';
+  button.style.backgroundImage = 'none';
+  button.style.margin = '0';
+  button.innerHTML = 'F';
+  button.addEventListener('click', () => {
+    toggleFullScreen(editor, toastuiEditor.value, height.value);
+  });
+  return button;
+};
+
+onMounted(() => {
+  const chartOptions = {
+    maxWidth: 800,
+    maxHeight: 400,
+  };
+  const computedOptions: EditorOptions = {
+    ...options?.value,
+
+    initialValue: modelValue.value ?? '',
+    initialEditType: initialEditType.value,
+    height: height.value,
+    previewStyle: previewStyle.value,
+    language: language.value,
+    autofocus: false,
+    usageStatistics: false,
+    el: toastuiEditor.value,
+    events: eventOptions,
+    hooks: { addImageBlobHook },
+    plugins: [[chart, chartOptions], [codeSyntaxHighlight, { highlighter: Prism }], tableMergedCell, uml],
+    toolbarItems: [
+      [
+        {
+          name: 'fullscreen',
+          el: createFullscreenButton(),
+          tooltip: 'Fullscreen',
+        },
+      ],
+      ['heading', 'bold', 'italic', 'strike'],
+      ['hr', 'quote'],
+      ['ul', 'ol', 'task', 'indent', 'outdent'],
+      ['table', 'image', 'link'],
+      ['code', 'codeblock'],
+      ['scrollSync'],
+    ],
+  };
+  editor = new Editor(computedOptions);
+  // markdown无值,html有值,则用设置html
+  if (!modelValue.value && html.value) {
+    editor.setHTML(html.value);
+    // 防止在切换编辑器时,因清空markdown值导致事件无效
+    nextTick().then(() => {
+      emit('update:modelValue', editor.getMarkdown());
+    });
+    return;
+  }
+  // 检查markdown生成的HTML和原HTML是否匹配
+  const currHtml = editor.getHTML();
+  if (modelValue.value && decodeHTML(html.value) !== currHtml) {
+    // 触发不匹配事件
+    emit('different', html.value, currHtml);
+    emit('update:html', currHtml);
+  }
+});
+onUnmounted(() => {
+  editorEvents.forEach((event) => {
+    editor.off(event);
+  });
+  editor.destroy();
+});
+const getHTML = () => editor.getHTML();
+const setHTML = (html: string): void => editor.setHTML(html);
+const getMarkdown = () => editor.getMarkdown();
+const setMarkdown = (markdown: string): void => editor.setMarkdown(markdown);
+const getRootElement = () => toastuiEditor.value;
+defineExpose({ getRootElement, getHTML, getMarkdown, setHTML, setMarkdown });
+</script>
+
+<template>
+  <!-- 在ElementPlus的对话框中,“更多”工具条按钮点击后,点击其它地方不会关闭工具条 -->
+  <div ref="toastuiEditor" v-on-click-outside="clickOutside"></div>
+</template>
+
+<style lang="scss" scoped>
+:deep(.ProseMirror),
+:deep(.toastui-editor-contents) {
+  font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB',
+    'Microsoft YaHei', 'WenQuanYi Micro Hei', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+}
+</style>

+ 1 - 0
src/components/TuiEditor/index.ts

@@ -0,0 +1 @@
+export { default as TuiEditor } from './TuiEditor.vue';

+ 76 - 0
src/components/TuiEditor/utils.ts

@@ -0,0 +1,76 @@
+import { ElMessageBox } from 'element-plus';
+import { imageUploadUrl } from '@/api/config';
+import { getAuthHeaders } from '@/utils/auth';
+import Editor from '@toast-ui/editor';
+
+/**
+ * 在对话框中使用编辑器时,点击更多工具按钮后,再点击页面其它地方,弹出的工具不会消失。需要认为的抛出一个点击事件。
+ */
+export const clickOutside = (event: Event) => {
+  if (event.bubbles || !event.cancelable || event.composed) {
+    const myEvent = new Event('click', { bubbles: false, cancelable: true, composed: false });
+    document.dispatchEvent(myEvent);
+  }
+};
+
+export const toggleFullScreen = (editor: Editor, element: HTMLElement, height: string): void => {
+  const style = element.style;
+  if (style.height !== '100vh') {
+    style.height = '100vh';
+    style.width = '100vw';
+    style.position = 'fixed';
+    style.zIndex = '10000000000';
+    style.top = '0px';
+    style.left = '0px';
+    style.backgroundColor = 'white';
+    editor.changePreviewStyle('vertical');
+  } else {
+    style.height = height;
+    style.width = '';
+    style.position = '';
+    style.zIndex = '';
+    style.top = '';
+    style.left = '';
+    style.backgroundColor = '';
+    editor.changePreviewStyle('tab');
+  }
+};
+
+export const addImageBlobHook = (blob: Blob | File, callback: any): void => {
+  const xhr = new XMLHttpRequest();
+  xhr.open('POST', imageUploadUrl);
+
+  // xhr.upload.onprogress = (e) => {
+  //   (e.loaded / e.total) * 100;
+  // };
+
+  xhr.onload = () => {
+    if (xhr.status === 403) {
+      ElMessageBox.alert(`HTTP Error: ${xhr.status}`, { type: 'warning' });
+      return;
+    }
+
+    if (xhr.status < 200 || xhr.status >= 300) {
+      ElMessageBox.alert(`HTTP Error: ${xhr.status}`, { type: 'warning' });
+      return;
+    }
+
+    const json = JSON.parse(xhr.responseText);
+
+    if (!json || typeof json.url !== 'string') {
+      ElMessageBox.alert(`Invalid JSON: ${xhr.responseText}`, { type: 'warning' });
+      return;
+    }
+    callback(json.url);
+  };
+
+  xhr.onerror = () => {
+    ElMessageBox.alert(`Image upload failed due to a XHR Transport error. Code: ${xhr.status}`, { type: 'warning' });
+  };
+
+  const formData = new FormData();
+  formData.append('file', blob);
+
+  Object.entries(getAuthHeaders()).forEach(([key, value]: any) => xhr.setRequestHeader(key, value));
+  xhr.send(formData);
+};

+ 433 - 0
src/components/bpmnjs/context-pad/FlowableContextPadProvider.js

@@ -0,0 +1,433 @@
+import { assign, forEach, isArray, every } from 'min-dash';
+import { is } from 'bpmn-js/lib/util/ModelUtil';
+import { isExpanded, isEventSubProcess } from 'bpmn-js/lib/util/DiUtil';
+import { isAny } from 'bpmn-js/lib/features/modeling/util/ModelingUtil';
+import { getChildLanes } from 'bpmn-js/lib/features/modeling/util/LaneUtil';
+import { hasPrimaryModifier } from 'diagram-js/lib/util/Mouse';
+
+/**
+ * A provider for BPMN 2.0 elements context pad
+ * 将Task改为UserTask,并删除中间事件
+ * https://github.com/bpmn-io/bpmn-js/blob/develop/lib/features/context-pad/ContextPadProvider.js
+ */
+export default function FlowableContextPadProvider(config, injector, eventBus, contextPad, modeling, elementFactory, connect, create, popupMenu, canvas, rules, translate) {
+  config = config || {};
+
+  contextPad.registerProvider(this);
+
+  this._contextPad = contextPad;
+
+  this._modeling = modeling;
+
+  this._elementFactory = elementFactory;
+  this._connect = connect;
+  this._create = create;
+  this._popupMenu = popupMenu;
+  this._canvas = canvas;
+  this._rules = rules;
+  this._translate = translate;
+
+  if (config.autoPlace !== false) {
+    this._autoPlace = injector.get('autoPlace', false);
+  }
+
+  eventBus.on('create.end', 250, function (event) {
+    var context = event.context,
+      shape = context.shape;
+
+    if (!hasPrimaryModifier(event) || !contextPad.isOpen(shape)) {
+      return;
+    }
+
+    var entries = contextPad.getEntries(shape);
+
+    if (entries.replace) {
+      entries.replace.action.click(event, shape);
+    }
+  });
+}
+
+FlowableContextPadProvider.$inject = [
+  'config.contextPad',
+  'injector',
+  'eventBus',
+  'contextPad',
+  'modeling',
+  'elementFactory',
+  'connect',
+  'create',
+  'popupMenu',
+  'canvas',
+  'rules',
+  'translate',
+];
+
+FlowableContextPadProvider.prototype.getMultiElementContextPadEntries = function (elements) {
+  var modeling = this._modeling;
+
+  var actions = {};
+
+  if (this._isDeleteAllowed(elements)) {
+    assign(actions, {
+      delete: {
+        group: 'edit',
+        className: 'bpmn-icon-trash',
+        title: this._translate('Remove'),
+        action: {
+          click: function (event, elements) {
+            modeling.removeElements(elements.slice());
+          },
+        },
+      },
+    });
+  }
+
+  return actions;
+};
+
+/**
+ * @param {djs.model.Base[]} elements
+ * @return {boolean}
+ */
+FlowableContextPadProvider.prototype._isDeleteAllowed = function (elements) {
+  var baseAllowed = this._rules.allowed('elements.delete', {
+    elements: elements,
+  });
+
+  if (isArray(baseAllowed)) {
+    return every(baseAllowed, function (element) {
+      return includes(baseAllowed, element);
+    });
+  }
+
+  return baseAllowed;
+};
+
+FlowableContextPadProvider.prototype.getContextPadEntries = function (element) {
+  var contextPad = this._contextPad,
+    modeling = this._modeling,
+    elementFactory = this._elementFactory,
+    connect = this._connect,
+    create = this._create,
+    popupMenu = this._popupMenu,
+    canvas = this._canvas,
+    rules = this._rules,
+    autoPlace = this._autoPlace,
+    translate = this._translate;
+
+  var actions = {};
+
+  if (element.type === 'label') {
+    return actions;
+  }
+
+  var businessObject = element.businessObject;
+
+  function startConnect(event, element) {
+    connect.start(event, element);
+  }
+
+  function removeElement(e, element) {
+    modeling.removeElements([element]);
+  }
+
+  function getReplaceMenuPosition(element) {
+    var Y_OFFSET = 5;
+
+    var diagramContainer = canvas.getContainer(),
+      pad = contextPad.getPad(element).html;
+
+    var diagramRect = diagramContainer.getBoundingClientRect(),
+      padRect = pad.getBoundingClientRect();
+
+    var top = padRect.top - diagramRect.top;
+    var left = padRect.left - diagramRect.left;
+
+    var pos = {
+      x: left,
+      y: top + padRect.height + Y_OFFSET,
+    };
+
+    return pos;
+  }
+
+  /**
+   * Create an append action
+   *
+   * @param {string} type
+   * @param {string} className
+   * @param {string} [title]
+   * @param {Object} [options]
+   *
+   * @return {Object} descriptor
+   */
+  function appendAction(type, className, title, options) {
+    if (typeof title !== 'string') {
+      options = title;
+      title = translate('Append {type}', { type: type.replace(/^bpmn:/, '') });
+    }
+
+    function appendStart(event, element) {
+      var shape = elementFactory.createShape(assign({ type: type }, options));
+      create.start(event, shape, {
+        source: element,
+      });
+    }
+
+    var append = autoPlace
+      ? function (event, element) {
+          var shape = elementFactory.createShape(assign({ type: type }, options));
+
+          autoPlace.append(element, shape);
+        }
+      : appendStart;
+
+    return {
+      group: 'model',
+      className: className,
+      title: title,
+      action: {
+        dragstart: appendStart,
+        click: append,
+      },
+    };
+  }
+
+  function splitLaneHandler(count) {
+    return function (event, element) {
+      // actual split
+      modeling.splitLane(element, count);
+
+      // refresh context pad after split to
+      // get rid of split icons
+      contextPad.open(element, true);
+    };
+  }
+
+  if (isAny(businessObject, ['bpmn:Lane', 'bpmn:Participant']) && isExpanded(element)) {
+    var childLanes = getChildLanes(element);
+
+    assign(actions, {
+      'lane-insert-above': {
+        group: 'lane-insert-above',
+        className: 'bpmn-icon-lane-insert-above',
+        title: translate('Add Lane above'),
+        action: {
+          click: function (event, element) {
+            modeling.addLane(element, 'top');
+          },
+        },
+      },
+    });
+
+    if (childLanes.length < 2) {
+      if (element.height >= 120) {
+        assign(actions, {
+          'lane-divide-two': {
+            group: 'lane-divide',
+            className: 'bpmn-icon-lane-divide-two',
+            title: translate('Divide into two Lanes'),
+            action: {
+              click: splitLaneHandler(2),
+            },
+          },
+        });
+      }
+
+      if (element.height >= 180) {
+        assign(actions, {
+          'lane-divide-three': {
+            group: 'lane-divide',
+            className: 'bpmn-icon-lane-divide-three',
+            title: translate('Divide into three Lanes'),
+            action: {
+              click: splitLaneHandler(3),
+            },
+          },
+        });
+      }
+    }
+
+    assign(actions, {
+      'lane-insert-below': {
+        group: 'lane-insert-below',
+        className: 'bpmn-icon-lane-insert-below',
+        title: translate('Add Lane below'),
+        action: {
+          click: function (event, element) {
+            modeling.addLane(element, 'bottom');
+          },
+        },
+      },
+    });
+  }
+
+  if (is(businessObject, 'bpmn:FlowNode')) {
+    if (is(businessObject, 'bpmn:EventBasedGateway')) {
+      assign(actions, {
+        'append.receive-task': appendAction('bpmn:ReceiveTask', 'bpmn-icon-receive-task', translate('Append ReceiveTask')),
+        'append.message-intermediate-event': appendAction(
+          'bpmn:IntermediateCatchEvent',
+          'bpmn-icon-intermediate-event-catch-message',
+          translate('Append MessageIntermediateCatchEvent'),
+          { eventDefinitionType: 'bpmn:MessageEventDefinition' },
+        ),
+        'append.timer-intermediate-event': appendAction(
+          'bpmn:IntermediateCatchEvent',
+          'bpmn-icon-intermediate-event-catch-timer',
+          translate('Append TimerIntermediateCatchEvent'),
+          { eventDefinitionType: 'bpmn:TimerEventDefinition' },
+        ),
+        'append.condition-intermediate-event': appendAction(
+          'bpmn:IntermediateCatchEvent',
+          'bpmn-icon-intermediate-event-catch-condition',
+          translate('Append ConditionIntermediateCatchEvent'),
+          { eventDefinitionType: 'bpmn:ConditionalEventDefinition' },
+        ),
+        'append.signal-intermediate-event': appendAction(
+          'bpmn:IntermediateCatchEvent',
+          'bpmn-icon-intermediate-event-catch-signal',
+          translate('Append SignalIntermediateCatchEvent'),
+          { eventDefinitionType: 'bpmn:SignalEventDefinition' },
+        ),
+      });
+    } else if (isEventType(businessObject, 'bpmn:BoundaryEvent', 'bpmn:CompensateEventDefinition')) {
+      assign(actions, {
+        'append.compensation-activity': appendAction('bpmn:UserTask', 'bpmn-icon-user-task', translate('Append compensation activity'), {
+          isForCompensation: true,
+        }),
+      });
+    } else if (
+      !is(businessObject, 'bpmn:EndEvent') &&
+      !businessObject.isForCompensation &&
+      !isEventType(businessObject, 'bpmn:IntermediateThrowEvent', 'bpmn:LinkEventDefinition') &&
+      !isEventSubProcess(businessObject)
+    ) {
+      assign(actions, {
+        'append.end-event': appendAction('bpmn:EndEvent', 'bpmn-icon-end-event-none', translate('Append EndEvent')),
+        'append.gateway': appendAction('bpmn:ExclusiveGateway', 'bpmn-icon-gateway-none', translate('Append Gateway')),
+        'append.append-user-task': appendAction('bpmn:UserTask', 'bpmn-icon-user-task', translate('Append UserTask')),
+        // 'append.intermediate-event': appendAction('bpmn:IntermediateThrowEvent', 'bpmn-icon-intermediate-event-none', translate('Append Intermediate/Boundary Event')),
+      });
+    }
+  }
+
+  if (!popupMenu.isEmpty(element, 'bpmn-replace')) {
+    // Replace menu entry
+    assign(actions, {
+      replace: {
+        group: 'edit',
+        className: 'bpmn-icon-screw-wrench',
+        title: translate('Change type'),
+        action: {
+          click: function (event, element) {
+            var position = assign(getReplaceMenuPosition(element), {
+              cursor: { x: event.x, y: event.y },
+            });
+
+            popupMenu.open(element, 'bpmn-replace', position);
+          },
+        },
+      },
+    });
+  }
+
+  if (is(businessObject, 'bpmn:SequenceFlow')) {
+    assign(actions, {
+      'append.text-annotation': appendAction('bpmn:TextAnnotation', 'bpmn-icon-text-annotation'),
+    });
+  }
+
+  if (isAny(businessObject, ['bpmn:FlowNode', 'bpmn:InteractionNode', 'bpmn:DataObjectReference', 'bpmn:DataStoreReference'])) {
+    assign(actions, {
+      'append.text-annotation': appendAction('bpmn:TextAnnotation', 'bpmn-icon-text-annotation'),
+
+      connect: {
+        group: 'connect',
+        className: 'bpmn-icon-connection-multi',
+        title: translate('Connect using ' + (businessObject.isForCompensation ? '' : 'Sequence/MessageFlow or ') + 'Association'),
+        action: {
+          click: startConnect,
+          dragstart: startConnect,
+        },
+      },
+    });
+  }
+
+  if (is(businessObject, 'bpmn:TextAnnotation')) {
+    assign(actions, {
+      connect: {
+        group: 'connect',
+        className: 'bpmn-icon-connection-multi',
+        title: translate('Connect using Association'),
+        action: {
+          click: startConnect,
+          dragstart: startConnect,
+        },
+      },
+    });
+  }
+
+  if (isAny(businessObject, ['bpmn:DataObjectReference', 'bpmn:DataStoreReference'])) {
+    assign(actions, {
+      connect: {
+        group: 'connect',
+        className: 'bpmn-icon-connection-multi',
+        title: translate('Connect using DataInputAssociation'),
+        action: {
+          click: startConnect,
+          dragstart: startConnect,
+        },
+      },
+    });
+  }
+
+  if (is(businessObject, 'bpmn:Group')) {
+    assign(actions, {
+      'append.text-annotation': appendAction('bpmn:TextAnnotation', 'bpmn-icon-text-annotation'),
+    });
+  }
+
+  // delete element entry, only show if allowed by rules
+  var deleteAllowed = rules.allowed('elements.delete', { elements: [element] });
+
+  if (isArray(deleteAllowed)) {
+    // was the element returned as a deletion candidate?
+    deleteAllowed = deleteAllowed[0] === element;
+  }
+
+  if (deleteAllowed) {
+    assign(actions, {
+      delete: {
+        group: 'edit',
+        className: 'bpmn-icon-trash',
+        title: translate('Remove'),
+        action: {
+          click: removeElement,
+        },
+      },
+    });
+  }
+
+  return actions;
+};
+
+// helpers /////////
+
+function isEventType(eventBo, type, definition) {
+  var isType = eventBo.$instanceOf(type);
+  var isDefinition = false;
+
+  var definitions = eventBo.eventDefinitions || [];
+  forEach(definitions, function (def) {
+    if (def.$type === definition) {
+      isDefinition = true;
+    }
+  });
+
+  return isType && isDefinition;
+}
+
+function includes(array, item) {
+  return array.indexOf(item) !== -1;
+}

+ 6 - 0
src/components/bpmnjs/context-pad/index.js

@@ -0,0 +1,6 @@
+import FlowableContextPadProvider from './FlowableContextPadProvider';
+
+export default {
+  __init__: ['contextPadProvider'],
+  contextPadProvider: ['type', FlowableContextPadProvider],
+};

+ 14 - 0
src/components/bpmnjs/customTranslate/customTranslate.ts

@@ -0,0 +1,14 @@
+// https://github.com/bpmn-io/bpmn-js-examples/blob/master/i18n/app/customTranslate/customTranslate.js
+import translations from './translations';
+
+export default function customTranslate(template: string, replacements: Record<string, string>) {
+  replacements = replacements || {};
+
+  // Translate
+  template = translations[template] || template;
+
+  // Replace
+  return template.replace(/{([^}]+)}/g, function (_, key) {
+    return replacements[key] || '{' + key + '}';
+  });
+}

+ 15 - 0
src/components/bpmnjs/customTranslate/defaultBpmnXml.ts

@@ -0,0 +1,15 @@
+export default function defaultBpmnXml(key: string, name: string, category: string) {
+  return `<?xml version="1.0" encoding="UTF-8"?>
+  <definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
+               xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
+               xmlns:flowable="http://flowable.org/bpmn" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
+               xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC" xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
+               typeLanguage="http://www.w3.org/2001/XMLSchema" expressionLanguage="http://www.w3.org/1999/XPath"
+               targetNamespace="${category}">
+    <process id="${key}" name="${name}" isExecutable="true"></process>
+    <bpmndi:BPMNDiagram id="BPMNDiagram_1">
+      <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="${key}">
+      </bpmndi:BPMNPlane>
+    </bpmndi:BPMNDiagram>
+  </definitions>`;
+}

+ 349 - 0
src/components/bpmnjs/customTranslate/translations.ts

@@ -0,0 +1,349 @@
+// https://github.com/bpmn-io/bpmn-js-i18n/blob/master/translations/zn.js
+// export default {
+//   "Activate the create/remove space tool": "启动创建/删除空间工具",
+//   "Activate the global connect tool": "启动全局连接工具",
+//   "Activate the hand tool": "启动手动工具",
+//   "Activate the lasso tool": "启动 Lasso 工具",
+//   "Ad-hoc": "Ad-hoc子流程",
+//   "Add Lane above": "添加到通道之上",
+//   "Add Lane below": "添加到通道之下",
+//   "Append compensation activity": "追加补偿活动",
+//   "Append {type}": "追加 {type}",
+//   "Business Rule Task": "规则任务",
+//   "Call Activity": "引用流程",
+//   "Cancel Boundary Event": "取消边界事件",
+//   "Cancel End Event": "结束取消事件",
+//   "Change type": "更改类型",
+//   "Collapsed Pool": "折叠池",
+//   "Compensation Boundary Event": "补偿边界事件",
+//   "Compensation End Event": "结束补偿事件",
+//   "Compensation Intermediate Throw Event": "中间补偿抛出事件",
+//   "Compensation Start Event": "补偿启动事件",
+//   "Complex Gateway": "复杂网关",
+//   "Conditional Boundary Event (non-interrupting)": "条件边界事件 (非中断)",
+//   "Conditional Boundary Event": "条件边界事件",
+//   "Conditional Intermediate Catch Event": "中间条件捕获事件",
+//   "Conditional Start Event (non-interrupting)": "条件启动事件 (非中断)",
+//   "Conditional Start Event": "条件启动事件",
+//   "Connect using Association": "文本关联",
+//   "Connect using DataInputAssociation": "数据关联",
+//   "Connect using Sequence/MessageFlow or Association": "消息关联",
+//   "Create IntermediateThrowEvent/BoundaryEvent": "创建中间抛出/边界事件",
+//   "Create Pool/Participant": "创建池/参与者",
+//   "Create expanded SubProcess": "创建可折叠子流程",
+//   "Create {type}": "创建 {type}",
+//   "Divide into three Lanes": "分成三条通道",
+//   "Divide into two Lanes": "分成两条通道",
+//   "End Event": "结束事件",
+//   "Error Boundary Event": "错误边界事件",
+//   "Error End Event": "结束错误事件",
+//   "Error Start Event": "错误启动事件",
+//   "Escalation Boundary Event (non-interrupting)": "升级边界事件 (非中断)",
+//   "Escalation Boundary Event": "升级边界事件",
+//   "Escalation End Event": "结束升级事件",
+//   "Escalation Intermediate Throw Event": "中间升级抛出事件",
+//   "Escalation Start Event (non-interrupting)": "升级启动事件 (非中断)",
+//   "Escalation Start Event": "升级启动事件",
+//   "Event Sub Process": "事件子流程",
+//   "Event based Gateway": "事件网关",
+//   "Exclusive Gateway": "独占网关",
+//   "Expanded Pool": "展开池",
+//   "Inclusive Gateway": "包容网关",
+//   "Intermediate Throw Event": "中间抛出事件",
+//   "Link Intermediate Catch Event": "中间链接捕获事件",
+//   "Link Intermediate Throw Event": "中间链接抛出事件",
+//   "Loop": "循环",
+//   "Manual Task": "手动任务",
+//   "Message Boundary Event (non-interrupting)": "消息边界事件 (非中断)",
+//   "Message Boundary Event": "消息边界事件",
+//   "Message End Event": "结束消息事件",
+//   "Message Intermediate Catch Event": "中间消息捕获事件",
+//   "Message Intermediate Throw Event": "中间消息抛出事件",
+//   "Message Start Event (non-interrupting)": "消息启动事件 (非中断)",
+//   "Message Start Event": "消息启动事件",
+//   "Parallel Gateway": "并行网关",
+//   "Parallel Multi Instance": "并行多实例",
+//   "Receive Task": "接受任务",
+//   "Remove": "移除",
+//   "Script Task": "脚本任务",
+//   "Send Task": "发送任务",
+//   "Sequential Multi Instance": "串行多实例",
+//   "Service Task": "服务任务",
+//   "Signal Boundary Event (non-interrupting)": "信号边界事件 (非中断)",
+//   "Signal Boundary Event": "信号边界事件",
+//   "Signal End Event": "结束信号事件",
+//   "Signal Intermediate Catch Event": "中间信号捕获事件",
+//   "Signal Intermediate Throw Event": "中间信号抛出事件",
+//   "Signal Start Event (non-interrupting)": "信号启动事件 (非中断)",
+//   "Signal Start Event": "信号启动事件",
+//   "Start Event": "开始事件",
+//   "Sub Process (collapsed)": "可折叠子流程",
+//   "Sub Process (expanded)": "可展开子流程",
+//   "Sub Process": "子流程",
+//   "Task": "任务",
+//   "Terminate End Event": "终止边界事件",
+//   "Timer Boundary Event (non-interrupting)": "定时边界事件 (非中断)",
+//   "Timer Boundary Event": "定时边界事件",
+//   "Timer Intermediate Catch Event": "中间定时捕获事件",
+//   "Timer Start Event (non-interrupting)": "定时启动事件 (非中断)",
+//   "Timer Start Event": "定时启动事件",
+//   "Transaction": "事务",
+//   "User Task": "用户任务",
+//   "already rendered {element}": "{element} 已呈现",
+//   "diagram not part of bpmn:Definitions": "图表不是 bpmn:Definitions 的一部分",
+//   "element required": "需要元素",
+//   "element {element} referenced by {referenced}#{property} not yet drawn": "元素 {element} 的引用 {referenced}#{property} 尚未绘制",
+//   "failed to import {element}": "{element} 导入失败",
+//   "flow elements must be children of pools/participants": "元素必须是池/参与者的子级",
+//   "more than {count} child lanes": "超过 {count} 条通道",
+//   "no diagram to display": "没有要显示的图表",
+//   "no parent for {element} in {parent}": "在 {element} 中没有父元素 {parent}",
+//   "no process or collaboration to display": "没有可显示的流程或协作",
+//   "no shape type specified": "未指定形状类型",
+//   "out of bounds release": "越界释放"
+// } as Record<string,string>;
+
+// https://blog.csdn.net/qq_33721382/article/details/114419903
+export default {
+  // Labels
+  'Activate the global connect tool': '激活全局连接工具',
+  'Append {type}': '追加 {type}',
+  'Append EndEvent': '追加 结束事件 ',
+  'Append Task': '追加 任务',
+  'Append UserTask': '追加 用户任务',
+  'Append Gateway': '追加 网关',
+  'Append Intermediate/Boundary Event': '追加 中间/边界 事件',
+  'Add Lane above': '在上面添加道',
+  'Divide into two Lanes': '分割成两个道',
+  'Divide into three Lanes': '分割成三个道',
+  'Add Lane below': '在下面添加道',
+  'Append compensation activity': '追加补偿活动',
+  'Change type': '修改类型',
+  'Connect using Association': '使用关联连接',
+  'Connect using Sequence/MessageFlow or Association': '使用顺序/消息流或者关联连接',
+  'Connect using DataInputAssociation': '使用数据输入关联连接',
+  Remove: '移除',
+  'Activate the hand tool': '激活抓手工具',
+  'Activate the lasso tool': '激活套索工具',
+  'Activate the create/remove space tool': '激活创建/删除空间工具',
+  'Create expanded SubProcess': '创建扩展子过程',
+  'Create IntermediateThrowEvent/BoundaryEvent': '创建中间抛出事件/边界事件',
+  'Create Pool/Participant': '创建池/参与者',
+  'Parallel Multi Instance': '并行多重事件',
+  'Sequential Multi Instance': '时序多重事件',
+  DataObjectReference: '数据对象参考',
+  DataStoreReference: '数据存储参考',
+  Loop: '循环',
+  'Ad-hoc': '即席',
+  'Create {type}': '创建 {type}',
+  'Create Task': '创建任务',
+  'Create StartEvent': '创建开始事件',
+  'Create EndEvent': '创建结束事件',
+  'Create Group': '创建组',
+  Task: '任务',
+  'Send Task': '发送任务',
+  'Receive Task': '接收任务',
+  'User Task': '用户任务',
+  'Manual Task': '手工任务',
+  'Business Rule Task': '业务规则任务',
+  'Service Task': '服务任务',
+  'Script Task': '脚本任务',
+  'Call Activity': '调用活动',
+  'Sub Process (collapsed)': '子流程(折叠的)',
+  'Sub Process (expanded)': '子流程(展开的)',
+  'Start Event': '开始事件',
+  StartEvent: '开始事件',
+  'Intermediate Throw Event': '中间事件',
+  'End Event': '结束事件',
+  EndEvent: '结束事件',
+  'Create Gateway': '创建网关',
+  GateWay: '网关',
+  'Create Intermediate/Boundary Event': '创建中间/边界事件',
+  'Message Start Event': '消息开始事件',
+  'Timer Start Event': '定时开始事件',
+  'Conditional Start Event': '条件开始事件',
+  'Signal Start Event': '信号开始事件',
+  'Error Start Event': '错误开始事件',
+  'Escalation Start Event': '升级开始事件',
+  'Compensation Start Event': '补偿开始事件',
+  'Message Start Event (non-interrupting)': '消息开始事件(非中断)',
+  'Timer Start Event (non-interrupting)': '定时开始事件(非中断)',
+  'Conditional Start Event (non-interrupting)': '条件开始事件(非中断)',
+  'Signal Start Event (non-interrupting)': '信号开始事件(非中断)',
+  'Escalation Start Event (non-interrupting)': '升级开始事件(非中断)',
+  'Message Intermediate Catch Event': '消息中间捕获事件',
+  'Message Intermediate Throw Event': '消息中间抛出事件',
+  'Timer Intermediate Catch Event': '定时中间捕获事件',
+  'Escalation Intermediate Throw Event': '升级中间抛出事件',
+  'Conditional Intermediate Catch Event': '条件中间捕获事件',
+  'Link Intermediate Catch Event': '链接中间捕获事件',
+  'Link Intermediate Throw Event': '链接中间抛出事件',
+  'Compensation Intermediate Throw Event': '补偿中间抛出事件',
+  'Signal Intermediate Catch Event': '信号中间捕获事件',
+  'Signal Intermediate Throw Event': '信号中间抛出事件',
+  'Message End Event': '消息结束事件',
+  'Escalation End Event': '定时结束事件',
+  'Error End Event': '错误结束事件',
+  'Cancel End Event': '取消结束事件',
+  'Compensation End Event': '补偿结束事件',
+  'Signal End Event': '信号结束事件',
+  'Terminate End Event': '终止结束事件',
+  'Message Boundary Event': '消息边界事件',
+  'Message Boundary Event (non-interrupting)': '消息边界事件(非中断)',
+  'Timer Boundary Event': '定时边界事件',
+  'Timer Boundary Event (non-interrupting)': '定时边界事件(非中断)',
+  'Escalation Boundary Event': '升级边界事件',
+  'Escalation Boundary Event (non-interrupting)': '升级边界事件(非中断)',
+  'Conditional Boundary Event': '条件边界事件',
+  'Conditional Boundary Event (non-interrupting)': '条件边界事件(非中断)',
+  'Error Boundary Event': '错误边界事件',
+  'Cancel Boundary Event': '取消边界事件',
+  'Signal Boundary Event': '信号边界事件',
+  'Signal Boundary Event (non-interrupting)': '信号边界事件(非中断)',
+  'Compensation Boundary Event': '补偿边界事件',
+  'Exclusive Gateway': '互斥网关',
+  'Parallel Gateway': '并行网关',
+  'Inclusive Gateway': '相容网关',
+  'Complex Gateway': '复杂网关',
+  'Event based Gateway': '事件网关',
+  Transaction: '转运',
+  'Sub Process': '子流程',
+  'Event Sub Process': '事件子流程',
+  'Collapsed Pool': '折叠池',
+  'Expanded Pool': '展开池',
+  // Errors
+  'no parent for {element} in {parent}': '在{parent}里,{element}没有父类',
+  'no shape type specified': '没有指定的形状类型',
+  'flow elements must be children of pools/participants': '流元素必须是池/参与者的子类',
+  'out of bounds release': 'out of bounds release',
+  'more than {count} child lanes': '子道大于{count} ',
+  'element required': '元素不能为空',
+  'diagram not part of bpmn:Definitions': '流程图不符合bpmn规范',
+  'no diagram to display': '没有可展示的流程图',
+  'no process or collaboration to display': '没有可展示的流程/协作',
+  'element {element} referenced by {referenced}#{property} not yet drawn': '由{referenced}#{property}引用的{element}元素仍未绘制',
+  'already rendered {element}': '{element} 已被渲染',
+  'failed to import {element}': '导入{element}失败',
+  //属性面板的参数
+  Id: '编号',
+  Name: '名称',
+  General: '常规',
+  Details: '详情',
+  'Message Name': '消息名称',
+  Message: '消息',
+  Initiator: '创建者',
+  'Asynchronous Continuations': '持续异步',
+  'Asynchronous Before': '异步前',
+  'Asynchronous After': '异步后',
+  'Job Configuration': '工作配置',
+  Exclusive: '排除',
+  'Job Priority': '工作优先级',
+  'Retry Time Cycle': '重试时间周期',
+  Documentation: '文档',
+  'Element documentation': '元素文档',
+  'History Configuration': '历史配置',
+  'History Time To Live': '历史的生存时间',
+  Forms: '表单',
+  'Form Key': '表单key',
+  'Form Fields': '表单字段',
+  'Business Key': '业务key',
+  'Form Field': '表单字段',
+  ID: '编号',
+  Type: '类型',
+  Label: '名称',
+  'Default Value': '默认值',
+  Validation: '校验',
+  'Add Constraint': '添加约束',
+  Config: '配置',
+  Properties: '属性',
+  'Add Property': '添加属性',
+  Value: '值',
+  Add: '添加',
+  Values: '值',
+  'Add Value': '添加值',
+  Listeners: '监听器',
+  'Execution Listener': '执行监听',
+  'Event Type': '事件类型',
+  'Listener Type': '监听器类型',
+  'Java Class': 'Java类',
+  Expression: '表达式',
+  'Must provide a value': '必须提供一个值',
+  'Delegate Expression': '代理表达式',
+  Script: '脚本',
+  'Script Format': '脚本格式',
+  'Script Type': '脚本类型',
+  'Inline Script': '内联脚本',
+  'External Script': '外部脚本',
+  Resource: '资源',
+  'Field Injection': '字段注入',
+  Extensions: '扩展',
+  'Input/Output': '输入/输出',
+  'Input Parameters': '输入参数',
+  'Output Parameters': '输出参数',
+  Parameters: '参数',
+  'Output Parameter': '输出参数',
+  'Timer Definition Type': '定时器定义类型',
+  'Timer Definition': '定时器定义',
+  Date: '日期',
+  Duration: '持续',
+  Cycle: '循环',
+  Signal: '信号',
+  'Signal Name': '信号名称',
+  Escalation: '升级',
+  Error: '错误',
+  'Link Name': '链接名称',
+  Condition: '条件名称',
+  'Variable Name': '变量名称',
+  'Variable Event': '变量事件',
+  'Specify more than one variable change event as a comma separated list.': '多个变量事件以逗号隔开',
+  'Wait for Completion': '等待完成',
+  'Activity Ref': '活动参考',
+  'Version Tag': '版本标签',
+  Executable: '可执行文件',
+  'External Task Configuration': '扩展任务配置',
+  'Task Priority': '任务优先级',
+  External: '外部',
+  Connector: '连接器',
+  'Must configure Connector': '必须配置连接器',
+  'Connector Id': '连接器编号',
+  Implementation: '实现方式',
+  'Field Injections': '字段注入',
+  Fields: '字段',
+  'Result Variable': '结果变量',
+  Topic: '主题',
+  'Configure Connector': '配置连接器',
+  'Input Parameter': '输入参数',
+  Assignment: '用户分配',
+  Assignee: '处理人',
+  'Candidate Users': '候选用户',
+  'Candidate Groups': '候选角色',
+  'Due Date': '到期时间',
+  'Follow Up Date': '跟踪日期',
+  Priority: '优先级',
+  'The follow up date as an EL expression (e.g. ${someDate} or an ISO date (e.g. 2015-06-26T09:54:00)':
+    '跟踪日期必须符合EL表达式,如: ${someDate} ,或者一个ISO标准日期,如:2015-06-26T09:54:00',
+  'The due date as an EL expression (e.g. ${someDate} or an ISO date (e.g. 2015-06-26T09:54:00)':
+    '跟踪日期必须符合EL表达式,如: ${someDate} ,或者一个ISO标准日期,如:2015-06-26T09:54:00',
+  Variables: '变量',
+  'Candidate Starter Configuration': '候选开始配置',
+  'Task Listener': '任务监听器',
+  'Candidate Starter Groups': '候选开始组',
+  'Candidate Starter Users': '候选开始用户',
+  'Tasklist Configuration': '任务列表配置',
+  Startable: '启动',
+  'Specify more than one group as a comma separated list.': '指定多个组,用逗号分隔',
+  'Specify more than one user as a comma separated list.': '指定多个用户,用逗号分隔',
+  'This maps to the process definition key.': '这会映射为流程定义的键',
+  'CallActivity Type': '调用活动类型',
+  'Condition Type': '条件类型',
+  'Create UserTask': '创建用户任务',
+  'Create CallActivity': '创建调用活动',
+  'Called Element': '调用元素',
+  'Create DataObjectReference': '创建数据对象引用',
+  'Create DataStoreReference': '创建数据存储引用',
+  'Multi Instance': '多实例',
+  'Loop Cardinality': '实例数量',
+  Collection: '任务参与人列表',
+  'Element Variable': '元素变量',
+  'Completion Condition': '完成条件',
+} as Record<string, string>;

+ 32 - 0
src/components/bpmnjs/descriptors/flowable.json

@@ -0,0 +1,32 @@
+{
+  "name": "Flowable",
+  "prefix": "flowable",
+  "uri": "http://flowable.org/bpmn",
+  "xml": {
+    "tagAlias": "lowerCase"
+  },
+  "associations": [],
+  "types": [
+    {
+      "name": "Assignment",
+      "extends": ["bpmn:UserTask"],
+      "properties": [
+        {
+          "name": "assignee",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "candidateUsers",
+          "isAttr": true,
+          "type": "String"
+        },
+        {
+          "name": "candidateGroups",
+          "isAttr": true,
+          "type": "String"
+        }
+      ]
+    }
+  ]
+}

+ 160 - 0
src/components/bpmnjs/palette/FlowablePaletteProvider.js

@@ -0,0 +1,160 @@
+import { assign } from 'min-dash';
+import { getDi } from 'bpmn-js/lib/util/ModelUtil';
+
+/**
+ * A palette provider for BPMN 2.0 elements.
+ * 将Task改为UserTask,并删除几个不常用的对象。
+ * https://github.com/bpmn-io/bpmn-js/blob/develop/lib/features/palette/PaletteProvider.js
+ * https://github.com/bpmn-io/bpmn-js-nyan/blob/master/lib/nyan/palette/NyanPaletteProvider.js
+ */
+export default function FlowablePaletteProvider(palette, create, elementFactory, spaceTool, lassoTool, handTool, globalConnect, translate) {
+  this._palette = palette;
+  this._create = create;
+  this._elementFactory = elementFactory;
+  this._spaceTool = spaceTool;
+  this._lassoTool = lassoTool;
+  this._handTool = handTool;
+  this._globalConnect = globalConnect;
+  this._translate = translate;
+
+  palette.registerProvider(this);
+}
+
+FlowablePaletteProvider.$inject = ['palette', 'create', 'elementFactory', 'spaceTool', 'lassoTool', 'handTool', 'globalConnect', 'translate'];
+
+FlowablePaletteProvider.prototype.getPaletteEntries = function (element) {
+  var actions = {},
+    create = this._create,
+    elementFactory = this._elementFactory,
+    spaceTool = this._spaceTool,
+    lassoTool = this._lassoTool,
+    handTool = this._handTool,
+    globalConnect = this._globalConnect,
+    translate = this._translate;
+
+  function createAction(type, group, className, title, options) {
+    function createListener(event) {
+      var shape = elementFactory.createShape(assign({ type: type }, options));
+
+      if (options) {
+        var di = getDi(shape);
+        di.isExpanded = options.isExpanded;
+      }
+
+      create.start(event, shape);
+    }
+
+    var shortType = type.replace(/^bpmn:/, '');
+
+    return {
+      group: group,
+      className: className,
+      title: title || translate('Create {type}', { type: shortType }),
+      action: {
+        dragstart: createListener,
+        click: createListener,
+      },
+    };
+  }
+
+  function createSubprocess(event) {
+    var subProcess = elementFactory.createShape({
+      type: 'bpmn:SubProcess',
+      x: 0,
+      y: 0,
+      isExpanded: true,
+    });
+
+    var startEvent = elementFactory.createShape({
+      type: 'bpmn:StartEvent',
+      x: 40,
+      y: 82,
+      parent: subProcess,
+    });
+
+    create.start(event, [subProcess, startEvent], {
+      hints: {
+        autoSelect: [subProcess],
+      },
+    });
+  }
+
+  function createParticipant(event) {
+    create.start(event, elementFactory.createParticipantShape());
+  }
+
+  assign(actions, {
+    'hand-tool': {
+      group: 'tools',
+      className: 'bpmn-icon-hand-tool',
+      title: translate('Activate the hand tool'),
+      action: {
+        click: function (event) {
+          handTool.activateHand(event);
+        },
+      },
+    },
+    'lasso-tool': {
+      group: 'tools',
+      className: 'bpmn-icon-lasso-tool',
+      title: translate('Activate the lasso tool'),
+      action: {
+        click: function (event) {
+          lassoTool.activateSelection(event);
+        },
+      },
+    },
+    'space-tool': {
+      group: 'tools',
+      className: 'bpmn-icon-space-tool',
+      title: translate('Activate the create/remove space tool'),
+      action: {
+        click: function (event) {
+          spaceTool.activateSelection(event);
+        },
+      },
+    },
+    'global-connect-tool': {
+      group: 'tools',
+      className: 'bpmn-icon-connection-multi',
+      title: translate('Activate the global connect tool'),
+      action: {
+        click: function (event) {
+          globalConnect.start(event);
+        },
+      },
+    },
+    'tool-separator': {
+      group: 'tools',
+      separator: true,
+    },
+    'create.start-event': createAction('bpmn:StartEvent', 'event', 'bpmn-icon-start-event-none', translate('Create StartEvent')),
+    // 'create.intermediate-event': createAction('bpmn:IntermediateThrowEvent', 'event', 'bpmn-icon-intermediate-event-none', translate('Create Intermediate/Boundary Event')),
+    'create.end-event': createAction('bpmn:EndEvent', 'event', 'bpmn-icon-end-event-none', translate('Create EndEvent')),
+    'create.exclusive-gateway': createAction('bpmn:ExclusiveGateway', 'gateway', 'bpmn-icon-gateway-none', translate('Create Gateway')),
+    'create.user-task': createAction('bpmn:UserTask', 'activity', 'bpmn-icon-user-task', translate('Create UserTask')),
+    // 'create.data-object': createAction('bpmn:DataObjectReference', 'data-object', 'bpmn-icon-data-object', translate('Create DataObjectReference')),
+    // 'create.data-store': createAction('bpmn:DataStoreReference', 'data-store', 'bpmn-icon-data-store', translate('Create DataStoreReference')),
+    'create.subprocess-expanded': {
+      group: 'activity',
+      className: 'bpmn-icon-subprocess-expanded',
+      title: translate('Create expanded SubProcess'),
+      action: {
+        dragstart: createSubprocess,
+        click: createSubprocess,
+      },
+    },
+    'create.participant-expanded': {
+      group: 'collaboration',
+      className: 'bpmn-icon-participant',
+      title: translate('Create Pool/Participant'),
+      action: {
+        dragstart: createParticipant,
+        click: createParticipant,
+      },
+    },
+    // 'create.group': createAction('bpmn:Group', 'artifact', 'bpmn-icon-group', translate('Create Group')),
+  });
+
+  return actions;
+};

+ 7 - 0
src/components/bpmnjs/palette/index.js

@@ -0,0 +1,7 @@
+import FlowablePaletteProvider from './FlowablePaletteProvider';
+
+export default {
+  // 可以考虑覆盖自带的 paletteProvider 全部自己定义
+  __init__: ['paletteProvider'],
+  paletteProvider: ['type', FlowablePaletteProvider],
+};

+ 89 - 0
src/components/bpmnjs/properties-panel/FlowablePropertiesPannel.vue

@@ -0,0 +1,89 @@
+<script setup lang="ts">
+import { ref, watch, toRefs, PropType } from 'vue';
+import { User, Setting } from '@element-plus/icons-vue';
+import LabelTip from '@/components/LabelTip.vue';
+import { is } from 'bpmn-js/lib/util/ModelUtil';
+
+const props = defineProps({ modeler: { type: Object, default: null }, queryRoles: { type: Function as PropType<() => Promise<any>>, required: true } });
+const { modeler } = toRefs(props);
+const element = ref();
+let modeling: any;
+let canvas: any;
+let selection: any;
+
+const propertyName = ref<string>();
+const propertyId = ref<string>();
+const propertyRoleIds = ref<number[]>();
+
+const roleList = ref<any[]>([]);
+
+const changeName = (name: string) => {
+  modeling.updateProperties(selection, { name });
+};
+const changeId = (id: string) => {
+  modeling.updateProperties(selection, { id });
+};
+const changeRoles = (roleIds: number[]) => {
+  modeling.updateProperties(selection, { candidateGroups: roleIds.join(',') });
+};
+
+const fetchRoleList = async () => {
+  roleList.value = await props.queryRoles();
+};
+
+watch(
+  () => modeler.value,
+  async () => {
+    if (modeler.value != null) {
+      fetchRoleList();
+      canvas = modeler.value.get('canvas');
+      modeling = modeler.value.get('modeling');
+      modeler.value.on('selection.changed', (event: any) => {
+        const { newSelection = [] } = event;
+        selection = newSelection[0] ?? canvas.getRootElement();
+        element.value = selection;
+        if (selection.businessObject) {
+          propertyName.value = selection.businessObject.name;
+          propertyId.value = selection.businessObject.id;
+          propertyRoleIds.value = selection.businessObject.candidateGroups?.split(',').map((item: string) => Number(item));
+        }
+      });
+    }
+  },
+  { immediate: true },
+);
+</script>
+
+<template>
+  <el-collapse model-value="1">
+    <el-collapse-item name="1">
+      <template #title>
+        <el-icon class="text-base"><Setting /></el-icon><span class="text-sm font-bold ml-1">{{ $t('flowable.groups.normal') }}</span>
+      </template>
+      <el-form label-width="96px">
+        <el-form-item>
+          <template #label><label-tip message="flowable.properties.name" /></template>
+          <el-input v-model="propertyName" :disabled="!is(element, 'bpmn:UserTask')" @input="changeName" />
+        </el-form-item>
+        <el-form-item class="mb-0">
+          <template #label><label-tip message="flowable.properties.id" /></template>
+          <el-input v-model="propertyId" disabled @input="changeId" />
+        </el-form-item>
+      </el-form>
+    </el-collapse-item>
+    <el-collapse-item v-if="is(element, 'bpmn:UserTask')" name="2">
+      <template #title>
+        <el-icon class="text-base"><User /></el-icon><span class="text-sm font-bold ml-1">{{ $t('flowable.groups.assignment') }}</span>
+      </template>
+      <el-form label-width="96px">
+        <el-form-item>
+          <template #label><label-tip message="flowable.properties.candidateGroups" /></template>
+          <el-select v-model="propertyRoleIds" multiple class="w-full" @change="changeRoles">
+            <el-option v-for="item in roleList" :key="item.id" :label="item.name" :value="item.id" />
+          </el-select>
+          <!-- <el-input v-model="candidateGroups" @input="changeCandidateGroups" /> -->
+        </el-form-item>
+      </el-form>
+    </el-collapse-item>
+  </el-collapse>
+</template>

+ 73 - 0
src/components/bpmnjs/provider/bpmn/BpmnPropertiesProvider.js

@@ -0,0 +1,73 @@
+import { Group } from '@bpmn-io/properties-panel';
+import { ExecutableProps, IdProps, NameProps, ProcessProps, DocumentationProps } from './properties';
+
+function GeneralGroup(element, translate) {
+  const entries = [...NameProps({ element }), ...IdProps({ element }), ...ProcessProps({ element }), ...ExecutableProps({ element })];
+  return {
+    id: 'general',
+    label: translate('General'),
+    entries,
+    component: Group,
+  };
+}
+
+function DocumentationGroup(element, translate) {
+  const entries = [...DocumentationProps({ element })];
+  return {
+    id: 'documentation',
+    label: translate('Documentation'),
+    entries,
+    component: Group,
+  };
+}
+
+function getGroups(element, translate) {
+  const groups = [GeneralGroup(element, translate), DocumentationGroup(element, translate)];
+  // contract: if a group returns null, it should not be displayed at all
+  return groups.filter((group) => group !== null);
+}
+
+/**
+ * A provider with a `#getGroups(element)` method
+ * that exposes groups for a diagram element.
+ *
+ * @param {PropertiesPanel} propertiesPanel
+ * @param {Function} translate
+ */
+export default function BpmnPropertiesProvider(propertiesPanel, translate) {
+  // API ////////
+
+  /**
+   * Return the groups provided for the given element.
+   *
+   * @param {DiagramElement} element
+   *
+   * @return {(Object[]) => (Object[])} groups middleware
+   */
+  this.getGroups = function (element) {
+    /**
+     * We return a middleware that modifies
+     * the existing groups.
+     *
+     * @param {Object[]} groups
+     *
+     * @return {Object[]} modified groups
+     */
+    return function (groups) {
+      // Add the "magic" group
+      // if(is(element, 'bpmn:StartEvent')) {
+      //   groups.push(createMagicGroup(element, translate));
+      // }
+      groups = groups.concat(getGroups(element, translate));
+      return groups;
+    };
+  };
+  // registration ////////
+
+  // Register our custom magic properties provider.
+  // Use a lower priority to ensure it is loaded after
+  // the basic BPMN properties.
+  propertiesPanel.registerProvider(this);
+}
+
+BpmnPropertiesProvider.$inject = ['propertiesPanel', 'translate'];

+ 6 - 0
src/components/bpmnjs/provider/bpmn/index.js

@@ -0,0 +1,6 @@
+import BpmnPropertiesProvider from './BpmnPropertiesProvider';
+
+export default {
+  __init__: ['bpmnPropertiesProvider'],
+  bpmnPropertiesProvider: ['type', BpmnPropertiesProvider],
+};

+ 155 - 0
src/components/bpmnjs/provider/bpmn/properties/DocumentationProps.js

@@ -0,0 +1,155 @@
+import { getBusinessObject, is } from 'bpmn-js/lib/util/ModelUtil';
+import { TextAreaEntry, isTextAreaEntryEdited } from '@bpmn-io/properties-panel';
+import { useService } from 'bpmn-js-properties-panel';
+import { without } from 'min-dash';
+
+const DOCUMENTATION_TEXT_FORMAT = 'text/plain';
+
+/**
+ * @typedef { import('@bpmn-io/properties-panel').EntryDefinition } Entry
+ */
+
+/**
+ * @returns {Array<Entry>} entries
+ */
+export function DocumentationProps(props) {
+  const { element } = props;
+
+  const entries = [
+    {
+      id: 'documentation',
+      component: ElementDocumentationProperty,
+      isEdited: isTextAreaEntryEdited,
+    },
+  ];
+
+  if (hasProcessRef(element)) {
+    entries.push({
+      id: 'processDocumentation',
+      component: ProcessDocumentationProperty,
+      isEdited: isTextAreaEntryEdited,
+    });
+  }
+
+  return entries;
+}
+
+function ElementDocumentationProperty(props) {
+  const { element } = props;
+
+  const bpmnFactory = useService('bpmnFactory');
+  const commandStack = useService('commandStack');
+  const translate = useService('translate');
+  const debounce = useService('debounceInput');
+
+  const getValue = getDocumentation(getBusinessObject(element));
+
+  const setValue = setDocumentation(element, getBusinessObject(element), bpmnFactory, commandStack);
+
+  return TextAreaEntry({
+    element,
+    id: 'documentation',
+    label: translate('Element documentation'),
+    getValue,
+    setValue,
+    debounce,
+  });
+}
+
+function ProcessDocumentationProperty(props) {
+  const { element } = props;
+
+  const bpmnFactory = useService('bpmnFactory');
+  const commandStack = useService('commandStack');
+  const translate = useService('translate');
+  const debounce = useService('debounceInput');
+
+  const processRef = getBusinessObject(element).processRef;
+
+  const getValue = getDocumentation(processRef);
+
+  const setValue = setDocumentation(element, processRef, bpmnFactory, commandStack);
+
+  return TextAreaEntry({
+    element,
+    id: 'processDocumentation',
+    label: translate('Process documentation'),
+    getValue,
+    setValue,
+    debounce,
+  });
+}
+
+// helper ////////////////////////////
+
+function hasProcessRef(element) {
+  return is(element, 'bpmn:Participant') && element.businessObject.get('processRef');
+}
+
+function findDocumentation(docs) {
+  return docs.find(function (d) {
+    return (d.textFormat || DOCUMENTATION_TEXT_FORMAT) === DOCUMENTATION_TEXT_FORMAT;
+  });
+}
+
+/**
+ * Retrieves a documentation element from a given moddle element.
+ *
+ * @param {ModdleElement} businessObject
+ *
+ * @returns {ModdleElement} documentation element inside the given moddle element.
+ */
+function getDocumentation(businessObject) {
+  return function () {
+    const documentation = findDocumentation(businessObject && businessObject.get('documentation'));
+
+    return documentation && documentation.text;
+  };
+}
+
+/**
+ * Sets a documentation element for a given moddle element.
+ *
+ * @param {ModdleElement} businessObject
+ */
+function setDocumentation(element, businessObject, bpmnFactory, commandStack) {
+  return function (value) {
+    let documentation = findDocumentation(businessObject && businessObject.get('documentation'));
+
+    // (1) update or removing existing documentation
+    if (documentation) {
+      if (value) {
+        return commandStack.execute('element.updateModdleProperties', {
+          element,
+          moddleElement: documentation,
+          properties: {
+            text: value,
+          },
+        });
+      } else {
+        return commandStack.execute('element.updateModdleProperties', {
+          element,
+          moddleElement: businessObject,
+          properties: {
+            documentation: without(businessObject.get('documentation'), documentation),
+          },
+        });
+      }
+    }
+
+    // (2) create new documentation entry
+    if (value) {
+      documentation = bpmnFactory.create('bpmn:Documentation', {
+        text: value,
+      });
+
+      return commandStack.execute('element.updateModdleProperties', {
+        element,
+        moddleElement: businessObject,
+        properties: {
+          documentation: [...businessObject.get('documentation'), documentation],
+        },
+      });
+    }
+  };
+}

+ 79 - 0
src/components/bpmnjs/provider/bpmn/properties/ExecutableProps.js

@@ -0,0 +1,79 @@
+import { is } from 'bpmn-js/lib/util/ModelUtil';
+import { CheckboxEntry, isCheckboxEntryEdited } from '@bpmn-io/properties-panel';
+import { useService } from 'bpmn-js-properties-panel';
+
+/**
+ * @typedef { import('@bpmn-io/properties-panel').EntryDefinition } Entry
+ */
+
+/**
+ * @returns {Array<Entry>} entries
+ */
+export function ExecutableProps(props) {
+  const { element } = props;
+
+  if (!is(element, 'bpmn:Process') && !hasProcessRef(element)) {
+    return [];
+  }
+
+  return [
+    {
+      id: 'isExecutable',
+      component: Executable,
+      isEdited: isCheckboxEntryEdited,
+    },
+  ];
+}
+
+function Executable(props) {
+  const { element } = props;
+
+  const modeling = useService('modeling');
+  const commandStack = useService('commandStack');
+  const translate = useService('translate');
+
+  let getValue, setValue;
+
+  setValue = (value) => {
+    modeling.updateProperties(element, {
+      isExecutable: value,
+    });
+  };
+
+  getValue = (element) => {
+    return element.businessObject.isExecutable;
+  };
+
+  // handle properties on processRef level for participants
+  if (is(element, 'bpmn:Participant')) {
+    const process = element.businessObject.get('processRef');
+
+    setValue = (value) => {
+      commandStack.execute('element.updateModdleProperties', {
+        element,
+        moddleElement: process,
+        properties: {
+          isExecutable: value,
+        },
+      });
+    };
+
+    getValue = () => {
+      return process.get('isExecutable');
+    };
+  }
+
+  return CheckboxEntry({
+    element,
+    id: 'isExecutable',
+    label: translate('Executable'),
+    getValue,
+    setValue,
+  });
+}
+
+// helper /////////////////////
+
+function hasProcessRef(element) {
+  return is(element, 'bpmn:Participant') && element.businessObject.get('processRef');
+}

+ 55 - 0
src/components/bpmnjs/provider/bpmn/properties/IdProps.js

@@ -0,0 +1,55 @@
+import { getBusinessObject, is } from 'bpmn-js/lib/util/ModelUtil';
+import { TextFieldEntry, isTextFieldEntryEdited } from '@bpmn-io/properties-panel';
+import { useService } from 'bpmn-js-properties-panel';
+import { isIdValid } from '../utils/ValidationUtil';
+
+/**
+ * @typedef { import('@bpmn-io/properties-panel').EntryDefinition } Entry
+ */
+
+/**
+ * @returns {Array<Entry>} entries
+ */
+export function IdProps() {
+  return [
+    {
+      id: 'id',
+      component: Id,
+      isEdited: isTextFieldEntryEdited,
+    },
+  ];
+}
+
+function Id(props) {
+  const { element } = props;
+
+  const modeling = useService('modeling');
+  const debounce = useService('debounceInput');
+  const translate = useService('translate');
+
+  const setValue = (value) => {
+    modeling.updateProperties(element, {
+      id: value,
+    });
+  };
+
+  const getValue = (element) => {
+    return element.businessObject.id;
+  };
+
+  const validate = (value) => {
+    const businessObject = getBusinessObject(element);
+
+    return isIdValid(businessObject, value, translate);
+  };
+
+  return TextFieldEntry({
+    element,
+    id: 'id',
+    label: translate(is(element, 'bpmn:Participant') ? 'Participant ID' : 'ID'),
+    getValue,
+    setValue,
+    debounce,
+    validate,
+  });
+}

+ 124 - 0
src/components/bpmnjs/provider/bpmn/properties/NameProps.js

@@ -0,0 +1,124 @@
+import { getBusinessObject, is } from 'bpmn-js/lib/util/ModelUtil';
+import { isAny } from 'bpmn-js/lib/features/modeling/util/ModelingUtil';
+import { add as collectionAdd } from 'diagram-js/lib/util/Collections';
+import { TextFieldEntry, isTextFieldEntryEdited } from '@bpmn-io/properties-panel';
+import { useService } from 'bpmn-js-properties-panel';
+
+/**
+ * @typedef { import('@bpmn-io/properties-panel').EntryDefinition } Entry
+ */
+
+/**
+ * @returns {Array<Entry>} entries
+ */
+export function NameProps(props) {
+  const { element } = props;
+
+  if (isAny(element, ['bpmn:Collaboration', 'bpmn:DataAssociation', 'bpmn:Association'])) {
+    return [];
+  }
+
+  return [
+    {
+      id: 'name',
+      component: Name,
+      isEdited: isTextFieldEntryEdited,
+    },
+  ];
+}
+
+function Name(props) {
+  const { element } = props;
+
+  const modeling = useService('modeling');
+  const debounce = useService('debounceInput');
+  const canvas = useService('canvas');
+  const bpmnFactory = useService('bpmnFactory');
+  const translate = useService('translate');
+
+  // (1) default: name
+  let options = {
+    element,
+    id: 'name',
+    label: translate('Name'),
+    debounce,
+    setValue: (value) => {
+      modeling.updateProperties(element, {
+        name: value,
+      });
+    },
+    getValue: (element) => {
+      return element.businessObject.name;
+    },
+  };
+
+  // (2) text annotations
+  if (is(element, 'bpmn:TextAnnotation')) {
+    options = {
+      ...options,
+      setValue: (value) => {
+        modeling.updateProperties(element, {
+          text: value,
+        });
+      },
+      getValue: (element) => {
+        return element.businessObject.text;
+      },
+    };
+  }
+
+  // (3) groups
+  else if (is(element, 'bpmn:Group')) {
+    options = {
+      ...options,
+      setValue: (value) => {
+        const businessObject = getBusinessObject(element),
+          categoryValueRef = businessObject.categoryValueRef;
+
+        if (!categoryValueRef) {
+          initializeCategory(businessObject, canvas.getRootElement(), bpmnFactory);
+        }
+
+        modeling.updateLabel(element, value);
+      },
+      getValue: (element) => {
+        const businessObject = getBusinessObject(element),
+          categoryValueRef = businessObject.categoryValueRef;
+
+        return categoryValueRef && categoryValueRef.value;
+      },
+    };
+  }
+
+  // (4) participants (only update label)
+  else if (is(element, 'bpmn:Participant')) {
+    options.label = translate('Participant Name');
+  }
+
+  return TextFieldEntry(options);
+}
+
+// helpers ////////////////////////
+
+function initializeCategory(businessObject, rootElement, bpmnFactory) {
+  const definitions = getBusinessObject(rootElement).$parent;
+
+  const categoryValue = createCategoryValue(definitions, bpmnFactory);
+
+  businessObject.categoryValueRef = categoryValue;
+}
+
+function createCategoryValue(definitions, bpmnFactory) {
+  const categoryValue = bpmnFactory.create('bpmn:CategoryValue');
+
+  const category = bpmnFactory.create('bpmn:Category', {
+    categoryValue: [categoryValue],
+  });
+
+  // add to correct place
+  collectionAdd(definitions.get('rootElements'), category);
+  getBusinessObject(category).$parent = definitions;
+  getBusinessObject(categoryValue).$parent = category;
+
+  return categoryValue;
+}

+ 107 - 0
src/components/bpmnjs/provider/bpmn/properties/ProcessProps.js

@@ -0,0 +1,107 @@
+import { is } from 'bpmn-js/lib/util/ModelUtil';
+import { TextFieldEntry, isTextFieldEntryEdited } from '@bpmn-io/properties-panel';
+import { useService } from 'bpmn-js-properties-panel';
+import { isIdValid } from '../utils/ValidationUtil';
+
+/**
+ * @typedef { import('@bpmn-io/properties-panel').EntryDefinition } Entry
+ */
+
+/**
+ * @returns {Array<Entry>} entries
+ */
+export function ProcessProps(props) {
+  const { element } = props;
+
+  if (!hasProcessRef(element)) {
+    return [];
+  }
+
+  return [
+    {
+      id: 'processId',
+      component: ProcessId,
+      isEdited: isTextFieldEntryEdited,
+    },
+    {
+      id: 'processName',
+      component: ProcessName,
+      isEdited: isTextFieldEntryEdited,
+    },
+  ];
+}
+
+function ProcessName(props) {
+  const { element } = props;
+
+  const commandStack = useService('commandStack');
+  const translate = useService('translate');
+  const debounce = useService('debounceInput');
+  const process = element.businessObject.get('processRef');
+
+  const getValue = () => {
+    return process.get('name');
+  };
+
+  const setValue = (value) => {
+    commandStack.execute('element.updateModdleProperties', {
+      element,
+      moddleElement: process,
+      properties: {
+        name: value,
+      },
+    });
+  };
+
+  return TextFieldEntry({
+    element,
+    id: 'processName',
+    label: translate('Process name'),
+    getValue,
+    setValue,
+    debounce,
+  });
+}
+
+function ProcessId(props) {
+  const { element } = props;
+
+  const commandStack = useService('commandStack');
+  const translate = useService('translate');
+  const debounce = useService('debounceInput');
+  const process = element.businessObject.get('processRef');
+
+  const getValue = () => {
+    return process.get('id');
+  };
+
+  const setValue = (value) => {
+    commandStack.execute('element.updateModdleProperties', {
+      element,
+      moddleElement: process,
+      properties: {
+        id: value,
+      },
+    });
+  };
+
+  const validate = (value) => {
+    return isIdValid(process, value, translate);
+  };
+
+  return TextFieldEntry({
+    element,
+    id: 'processId',
+    label: translate('Process ID'),
+    getValue,
+    setValue,
+    debounce,
+    validate,
+  });
+}
+
+// helper ////////////////
+
+function hasProcessRef(element) {
+  return is(element, 'bpmn:Participant') && element.businessObject.get('processRef');
+}

+ 5 - 0
src/components/bpmnjs/provider/bpmn/properties/index.js

@@ -0,0 +1,5 @@
+export { ExecutableProps } from './ExecutableProps';
+export { IdProps } from './IdProps';
+export { NameProps } from './NameProps';
+export { ProcessProps } from './ProcessProps';
+export { DocumentationProps } from './DocumentationProps';

+ 49 - 0
src/components/bpmnjs/provider/bpmn/utils/ValidationUtil.js

@@ -0,0 +1,49 @@
+const SPACE_REGEX = /\s/;
+
+// for QName validation as per http://www.w3.org/TR/REC-xml/#NT-NameChar
+const QNAME_REGEX = /^([a-z][\w-.]*:)?[a-z_][\w-.]*$/i;
+
+// for ID validation as per BPMN Schema (QName - Namespace)
+const ID_REGEX = /^[a-z_][\w-.]*$/i;
+
+/**
+ * checks whether the id value is valid
+ *
+ * @param {ModdleElement} element
+ * @param {String} idValue
+ * @param {Function} translate
+ *
+ * @return {String} error message
+ */
+export function isIdValid(element, idValue, translate) {
+  const assigned = element.$model.ids.assigned(idValue);
+  const idAlreadyExists = assigned && assigned !== element;
+
+  if (!idValue) {
+    return translate('ID must not be empty.');
+  }
+
+  if (idAlreadyExists) {
+    return translate('ID must be unique.');
+  }
+
+  return validateId(idValue, translate);
+}
+
+export function validateId(idValue, translate) {
+  if (containsSpace(idValue)) {
+    return translate('ID must not contain spaces.');
+  }
+
+  if (!ID_REGEX.test(idValue)) {
+    if (QNAME_REGEX.test(idValue)) {
+      return translate('ID must not contain prefix.');
+    }
+
+    return translate('ID must be a valid QName.');
+  }
+}
+
+export function containsSpace(value) {
+  return SPACE_REGEX.test(value);
+}

+ 80 - 0
src/components/bpmnjs/provider/flowable/FlowablePropertiesProvider.js

@@ -0,0 +1,80 @@
+import { is } from 'bpmn-js/lib/util/ModelUtil';
+import { Group } from '@bpmn-io/properties-panel';
+import { CandidateGroupsProps } from './properties';
+
+function AssignmentGroup(element, translate) {
+  const entries = [...CandidateGroupsProps({ element })];
+  const group = {
+    id: 'assignment',
+    label: translate('Assignment'),
+    entries,
+    component: Group,
+  };
+  if (group.entries.length) {
+    return group;
+  }
+  return null;
+}
+
+function getGroups(element, translate) {
+  const groups = [AssignmentGroup(element, translate)];
+  // contract: if a group returns null, it should not be displayed at all
+  return groups.filter((group) => group !== null);
+}
+
+// function createGroup(element, translate) {
+//   if (is(element, 'bpmn:Task')) {
+//     return {
+//       id: 'assignment',
+//       label: translate('Assignment'),
+//       entries: CandidateGroupsProps(element),
+//     };
+//   }
+//   return null;
+// }
+
+const LOW_PRIORITY = 500;
+
+/**
+ * A provider with a `#getGroups(element)` method
+ * that exposes groups for a diagram element.
+ *
+ * @param {PropertiesPanel} propertiesPanel
+ * @param {Function} translate
+ */
+export default function FlowablePropertiesProvider(propertiesPanel, translate) {
+  // API ////////
+
+  /**
+   * Return the groups provided for the given element.
+   *
+   * @param {DiagramElement} element
+   *
+   * @return {(Object[]) => (Object[])} groups middleware
+   */
+  this.getGroups = function (element) {
+    /**
+     * We return a middleware that modifies
+     * the existing groups.
+     *
+     * @param {Object[]} groups
+     *
+     * @return {Object[]} modified groups
+     */
+    return function (groups) {
+      // if (is(element, 'bpmn:Task')) {
+      //   groups.push(createGroup(element, translate));
+      // }
+      groups = groups.concat(getGroups(element, translate));
+      return groups;
+    };
+  };
+  // registration ////////
+
+  // Register our custom magic properties provider.
+  // Use a lower priority to ensure it is loaded after
+  // the basic BPMN properties.
+  propertiesPanel.registerProvider(LOW_PRIORITY, this);
+}
+
+FlowablePropertiesProvider.$inject = ['propertiesPanel', 'translate'];

+ 6 - 0
src/components/bpmnjs/provider/flowable/index.js

@@ -0,0 +1,6 @@
+import FlowablePropertiesProvider from './FlowablePropertiesProvider';
+
+export default {
+  __init__: ['flowablePropertiesProvider'],
+  flowablePropertiesProvider: ['type', FlowablePropertiesProvider],
+};

+ 40 - 0
src/components/bpmnjs/provider/flowable/properties/CandidateGroupsProps.js

@@ -0,0 +1,40 @@
+import { h } from '@bpmn-io/properties-panel/preact';
+import { TextFieldEntry, isTextFieldEntryEdited } from '@bpmn-io/properties-panel';
+import { useService } from 'bpmn-js-properties-panel';
+import { is } from 'bpmn-js/lib/util/ModelUtil';
+
+export function CandidateGroupsProps(props) {
+  const { element } = props;
+  if (!is(element, 'bpmn:Task')) {
+    return [];
+  }
+
+  return [
+    {
+      id: 'candidateGroups',
+      component: CandidateGroups,
+      isEdited: isTextFieldEntryEdited,
+    },
+  ];
+}
+
+export function CandidateGroups(props) {
+  const { element, id } = props;
+
+  const modeling = useService('modeling');
+  const translate = useService('translate');
+  const debounce = useService('debounceInput');
+
+  const getValue = () => {
+    return element.businessObject.candidateGroups || '';
+  };
+
+  const setValue = (value) => {
+    return modeling.updateProperties(element, {
+      candidateGroups: value,
+    });
+  };
+
+  return h(TextFieldEntry, { id, element, label: translate('Candidate Groups'), getValue, setValue, debounce });
+  // return <TextFieldEntry id={id} element={element} label={translate('Candidate Groups')} getValue={getValue} setValue={setValue} debounce={debounce} />;
+}

+ 1 - 0
src/components/bpmnjs/provider/flowable/properties/index.js

@@ -0,0 +1 @@
+export { CandidateGroupsProps } from './CandidateGroupsProps';

Деякі файли не було показано, через те що забагато файлів було змінено