Просмотр исходного кода

Merge branch 'feature/editor' into develop

cc12458 4 месяцев назад
Родитель
Сommit
00dd10b553

+ 1 - 0
index.html

@@ -5,6 +5,7 @@
   <link rel="icon" href="/favicon.ico">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>中医健康管理平台</title>
+  <link rel="stylesheet" href="/editor/preview.css">
 </head>
 <body>
 <div id="app"></div>

+ 5 - 0
package.json

@@ -13,6 +13,7 @@
     "format": "prettier --write src/"
   },
   "dependencies": {
+    "@alova/adapter-xhr": "^2.2.1",
     "@ant-design/icons-vue": "^7.0.1",
     "@logicflow/core": "^2.1.2",
     "@logicflow/extension": "^2.1.4",
@@ -22,6 +23,10 @@
     "@vueuse/components": "^10.11.0",
     "@vueuse/core": "^10.11.0",
     "@vueuse/router": "^10.11.1",
+    "@wangeditor/core": "^1.1.19",
+    "@wangeditor/editor": "^5.1.23",
+    "@wangeditor/plugin-ctrl-enter": "^1.1.2",
+    "@wangeditor/plugin-md": "^1.0.0",
     "alova": "^3.2.10",
     "ant-design-vue": "^4.2.3",
     "china-division": "^2.7.0",

Разница между файлами не показана из-за своего большого размера
+ 269 - 124
pnpm-lock.yaml


+ 65 - 0
public/editor/loader.css

@@ -0,0 +1,65 @@
+.loader-container {
+  position: fixed;
+  top: 50%;
+  left: 50%;
+  display: flex;
+  flex-direction: row;
+  transform: translate(-50%, -50%);
+}
+
+.loader-container div {
+  height: 20px;
+  width: 5px;
+  background: #FE4A49;
+  margin: 3px;
+  border-radius: 25px;
+}
+
+.loader-container div:nth-child(1) {
+  animation: loader 1s ease-in-out infinite 0s;
+}
+
+.loader-container div:nth-child(2) {
+  animation: loader 1s ease-in-out infinite 0.1s;
+}
+
+.loader-container div:nth-child(3) {
+  animation: loader 1s ease-in-out infinite 0.2s;
+}
+
+.loader-container div:nth-child(4) {
+  animation: loader 1s ease-in-out infinite 0.3s;
+}
+
+.loader-container div:nth-child(5) {
+  animation: loader 1s ease-in-out infinite 0.4s;
+}
+
+.loader-container div:nth-child(6) {
+  animation: loader 1s ease-in-out infinite 0.5s;
+}
+
+.loader-container div:nth-child(7) {
+  animation: loader 1s ease-in-out infinite 0.6s;
+}
+
+@keyframes loader {
+  0% {
+    transform: scaleY(1);
+    background: #FED766;
+  }
+  25% {
+    background: #009FB7;
+  }
+  50% {
+    transform: scaleY(2);
+    background: #59CD90;
+  }
+  75% {
+    background: #FE4A49;
+  }
+  100% {
+    transform: scaleY(1);
+    background: #D91E36;
+  }
+}

+ 258 - 0
public/editor/preview.css

@@ -0,0 +1,258 @@
+:root,
+:host {
+  --w-e-textarea-bg-color: #fff;
+  --w-e-textarea-color: #000;
+  --w-e-textarea-font-size: 16px;
+  --w-e-textarea-border-color: #ccc;
+  --w-e-textarea-slight-border-color: #e8e8e8;
+  --w-e-textarea-slight-color: #d4d4d4;
+  --w-e-textarea-slight-bg-color: #f5f2f0;
+  --w-e-textarea-selected-border-color: #B4D5FF;
+  --w-e-textarea-handler-bg-color: #4290f7;
+}
+
+.editor-generate-preview-wrapper {
+  padding-left: 1em;
+  padding-right: 1em;
+  font-size: var(--w-e-textarea-font-size);
+}
+
+.w-e-text-container {
+  position: relative;
+  margin: 0;
+  padding: 0;
+  height: 100%;
+  outline: none;
+  background-color: var(--w-e-textarea-bg-color);
+  color: var(--w-e-textarea-color);
+  box-sizing: border-box;
+}
+.w-e-text-container > *:first-child {
+  margin-top: 0;
+}
+
+.w-e-text-container li, .w-e-text-container p, .w-e-text-container td, .w-e-text-container th {
+  line-height: 1.5
+}
+
+.w-e-text-container h1 {
+  font-size: 2.25em;
+  margin: 0.67em 0;
+  font-weight: 700;
+}
+
+.w-e-text-container h2 {
+  font-size: 2em;
+  margin: 0.65em 0;
+  font-weight: 700;
+}
+
+.w-e-text-container h3 {
+  font-size: 1.75em;
+  margin: 0.65em 0;
+  font-weight: 600;
+}
+
+.w-e-text-container h4 {
+  font-size: 1.5em;
+  margin: 0.6em 0;
+  font-weight: 600;
+}
+
+.w-e-text-container h5 {
+  font-size: 1.25em;
+  margin: 0.5em 0;
+  font-weight: 600;
+}
+
+.w-e-text-container p {
+  font-size: 1em;
+  margin: 0.25em 0
+}
+
+.w-e-text-container span {
+  text-indent: 0
+}
+
+.w-e-text-container blockquote {
+  line-height: 1.5;
+  background-color: var(--w-e-textarea-slight-bg-color);
+  border-left: 8px solid var(--w-e-textarea-selected-border-color);
+  display: block;
+  font-size: 100%;
+  margin: 10px 0;
+  padding: 10px;
+}
+
+.w-e-text-container ul,
+.w-e-text-container ol {
+  padding-left: 2em;
+}
+
+.w-e-text-container [data-w-e-type="todo"] {
+  position: relative;
+  padding-left: 2em;
+
+}
+
+.w-e-text-container [data-w-e-type="todo"] input[type='checkbox'] {
+  position: absolute;
+  top: 0.35em;
+  left: 0.75em;
+}
+
+.w-e-text-container [data-w-e-type="todo"] input[type='checkbox']:disabled {
+  color: red;
+}
+
+.w-e-text-container hr {
+  background-color: var(--w-e-textarea-border-color);
+  display: block;
+  margin: 1em auto;
+  height: 1px;
+  border: 0;
+}
+
+
+.w-e-text-container img {
+  display: inline !important;
+  max-width: 100%;
+  min-width: 20px;
+  min-height: 20px;
+  object-fit: scale-down;
+  cursor: default;
+}
+
+.w-e-text-container video {
+  margin: auto;
+  max-width: 100%;
+}
+
+.w-e-text-container table {
+  margin: 1em 0;
+  border-collapse: collapse
+}
+
+.w-e-text-container table td,
+.w-e-text-container table th {
+  border: 1px solid var(--w-e-textarea-border-color);
+  line-height: 1.5;
+  min-width: 30px;
+  padding: 3px 5px;
+  text-align: left
+}
+
+.w-e-text-container table th {
+  background-color: var(--w-e-textarea-slight-bg-color);
+  font-weight: 700;
+  text-align: center
+}
+
+.w-e-text-container pre {
+
+}
+
+.w-e-text-container pre > code {
+  display: block;
+  margin: .5em 0;
+  padding: 1em;
+  background-color: var(--w-e-textarea-slight-bg-color);
+  border: 1px solid var(--w-e-textarea-slight-border-color);
+  border-radius: 4px 4px;
+  font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
+  font-size: 14px;
+  line-height: 1.5;
+  text-indent: 0;
+  text-align: left;
+  text-shadow: 0 1px #fff;
+  word-wrap: normal;
+  -webkit-hyphens: none;
+  hyphens: none;
+  overflow: auto;
+  -moz-tab-size: 4;
+  -o-tab-size: 4;
+  tab-size: 4;
+  white-space: pre;
+  word-break: normal;
+  word-spacing: normal;
+}
+
+.w-e-text-container pre > code .token.cdata,
+.w-e-text-container pre > code .token.comment,
+.w-e-text-container pre > code .token.doctype,
+.w-e-text-container pre > code .token.prolog {
+  color: #708090
+}
+
+.w-e-text-container pre > code .token.punctuation {
+  color: #999
+}
+
+.w-e-text-container pre > code .token.namespace {
+  opacity: .7
+}
+
+.w-e-text-container pre > code .token.boolean,
+.w-e-text-container pre > code .token.constant,
+.w-e-text-container pre > code .token.deleted,
+.w-e-text-container pre > code .token.number,
+.w-e-text-container pre > code .token.property,
+.w-e-text-container pre > code .token.symbol,
+.w-e-text-container pre > code .token.tag {
+  color: #905
+}
+
+.w-e-text-container pre > code .token.attr-name,
+.w-e-text-container pre > code .token.builtin,
+.w-e-text-container pre > code .token.char,
+.w-e-text-container pre > code .token.inserted,
+.w-e-text-container pre > code .token.selector,
+.w-e-text-container pre > code .token.string {
+  color: #690
+}
+
+.w-e-text-container pre > code .language-css .token.string,
+.w-e-text-container pre > code .style .token.string,
+.w-e-text-container pre > code .token.entity,
+.w-e-text-container pre > code .token.operator,
+.w-e-text-container pre > code .token.url {
+  color: #9a6e3a
+}
+
+.w-e-text-container pre > code .token.atrule,
+.w-e-text-container pre > code .token.attr-value,
+.w-e-text-container pre > code .token.keyword {
+  color: #07a
+}
+
+.w-e-text-container pre > code .token.class-name,
+.w-e-text-container pre > code .token.function {
+  color: #dd4a68
+}
+
+.w-e-text-container pre > code .token.important,
+.w-e-text-container pre > code .token.regex,
+.w-e-text-container pre > code .token.variable {
+  color: #e90
+}
+
+.w-e-text-container pre > code .token.bold,
+.w-e-text-container pre > code .token.important {
+  font-weight: 700
+}
+
+.w-e-text-container pre > code .token.italic {
+  font-style: italic
+}
+
+.w-e-text-container pre > code .token.entity {
+  cursor: help
+}
+
+
+.w-e-text-container code {
+  background-color: var(--w-e-textarea-slight-bg-color);
+  border-radius: 3px;
+  font-family: monospace;
+  padding: 3px
+}

+ 57 - 0
public/editor/preview.html

@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>加载中...</title>
+  <link href="preview.css" rel="stylesheet">
+  <link href="loader.css" rel="stylesheet">
+  <style>
+    html, body {
+      margin: 0;
+      padding: 0;
+    }
+  </style>
+</head>
+<body>
+<div class="loader-container">
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+  <div></div>
+</div>
+<div class="editor-generate-preview-wrapper">
+  <div class="header"></div>
+  <div class="content w-e-text-container"></div>
+  <div class="footer"></div>
+</div>
+<script>
+  const host = ``;
+
+  /* 渲染 header 和 footer  */
+  function render() {
+
+  }
+
+  async function load() {
+    try {
+      if (!host) throw { message: `请求地址为空` };
+      const request = await fetch(`${host}${location.search}&${location.hash.split('?')[1] || ''}`);
+      const result = await request.json();
+      document.title = result.title || `加载完成`;
+      document.querySelector('.w-e-text-container').innerHTML = result.content || `暂无内容`;
+      render(result);
+    } catch (e) {
+      document.querySelector('.w-e-text-container').innerHTML = e.message || `请求未成功`
+    } finally {
+      document.querySelector('.loader-container').style.display = 'none';
+    }
+  }
+
+  document.addEventListener('DOMContentLoaded', load);
+</script>
+</body>
+</html>

BIN
src/pages/editor/assets/iphone15-pro-max.png


+ 110 - 0
src/pages/editor/config.ts

@@ -0,0 +1,110 @@
+import groupIndentRaw from './icon/group-indent.svg?raw';
+
+export const toolbar = [
+  'bold',
+  'underline',
+  'italic',
+  'through',
+  'code',
+  'sub',
+  'sup',
+  'clearStyle',
+  'color',
+  'bgColor',
+  'fontSize',
+  'fontFamily',
+  'indent',
+  'delIndent',
+  'justifyLeft',
+  'justifyRight',
+  'justifyCenter',
+  'justifyJustify',
+  'lineHeight',
+  'insertImage',
+  'deleteImage',
+  'editImage',
+  'viewImageLink',
+  'imageWidth30',
+  'imageWidth50',
+  'imageWidth100',
+  'divider',
+  'emotion',
+  'insertLink',
+  'editLink',
+  'unLink',
+  'viewLink',
+  'codeBlock',
+  'blockquote',
+  'headerSelect',
+  'header1',
+  'header2',
+  'header3',
+  'header4',
+  'header5',
+  'todo',
+  'redo',
+  'undo',
+  'fullScreen',
+  'enter',
+  'bulletedList',
+  'numberedList',
+  'insertTable',
+  'deleteTable',
+  'insertTableRow',
+  'deleteTableRow',
+  'insertTableCol',
+  'deleteTableCol',
+  'tableHeader',
+  'tableFullWidth',
+  'insertVideo',
+  'uploadVideo',
+  'editVideoSize',
+  'uploadImage',
+  'codeSelectLang',
+] as const;
+
+export function getDefaultToolbarConfig() {
+  return [
+    'headerSelect',
+    'fontSize',
+    'fontFamily',
+    'lineHeight',
+    '|',
+    'bold',
+    'italic',
+    'underline',
+    'through',
+    'sup',
+    'sub',
+    'color',
+    'bgColor',
+    'clearStyle',
+    '|',
+    'justifyLeft',
+    'justifyCenter',
+    'justifyRight',
+    // 'justifyJustify',
+    {
+      key: 'group-indent',
+      title: '缩进',
+      iconSvg: groupIndentRaw,
+      menuKeys: ['indent', 'delIndent'],
+    },
+    '|',
+    'bulletedList',
+    'numberedList',
+    'todo',
+    '|',
+    'uploadImage',
+    'uploadVideo',
+    '|',
+    'insertTable',
+    'divider',
+    '|',
+    'undo',
+    'redo',
+    '|',
+    'fullScreen',
+    'preview',
+  ];
+}

+ 1 - 0
src/pages/editor/icon/delete.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024"><path d="M826.8032 356.5312c-19.328 0-36.3776 15.6928-36.3776 35.0464v524.2624c0 19.328-16 34.56-35.328 34.56H264.9344c-19.328 0-35.5072-15.3088-35.5072-34.56V390.0416c0-19.328-14.1568-35.0464-33.5104-35.0464s-33.5104 15.6928-33.5104 35.0464V915.712c0 57.9328 44.6208 108.288 102.528 108.288H755.2c57.9328 0 108.0832-50.4576 108.0832-108.288V391.4752c-0.1024-19.2512-17.1264-34.944-36.48-34.944z" p-id="9577"></path><path d="M437.1712 775.7568V390.6048c0-19.328-14.1568-35.0464-33.5104-35.0464s-33.5104 15.616-33.5104 35.0464v385.152c0 19.328 14.1568 35.0464 33.5104 35.0464s33.5104-15.7184 33.5104-35.0464zM649.7024 775.7568V390.6048c0-19.328-17.0496-35.0464-36.3776-35.0464s-36.3776 15.616-36.3776 35.0464v385.152c0 19.328 17.0496 35.0464 36.3776 35.0464s36.3776-15.7184 36.3776-35.0464zM965.0432 217.0368h-174.6176V145.5104c0-57.9328-47.2064-101.76-104.6528-101.76h-350.976c-57.8304 0-105.3952 43.8528-105.3952 101.76v71.5264H54.784c-19.4304 0-35.0464 14.1568-35.0464 33.5104 0 19.328 15.616 33.5104 35.0464 33.5104h910.3616c19.328 0 35.0464-14.1568 35.0464-33.5104 0-19.3536-15.6928-33.5104-35.1488-33.5104z m-247.3728 0H297.3952V145.5104c0-19.328 18.2016-34.7648 37.4272-34.7648h350.976c19.1488 0 31.872 15.1296 31.872 34.7648v71.5264z"></path></svg>

+ 1 - 0
src/pages/editor/icon/group-image.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024"><path d="M959.877 128l0.123 0.123v767.775l-0.123 0.122H64.102l-0.122-0.122V128.123l0.122-0.123h895.775zM960 64H64C28.795 64 0 92.795 0 128v768c0 35.205 28.795 64 64 64h896c35.205 0 64-28.795 64-64V128c0-35.205-28.795-64-64-64zM832 288.01c0 53.023-42.988 96.01-96.01 96.01s-96.01-42.987-96.01-96.01S682.967 192 735.99 192 832 234.988 832 288.01zM896 832H128V704l224.01-384 256 320h64l224.01-192z"></path></svg>

+ 1 - 0
src/pages/editor/icon/group-indent.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024"><path d="M0 64h1024v128H0z m384 192h640v128H384z m0 192h640v128H384z m0 192h640v128H384zM0 832h1024v128H0z m0-128V320l256 192z"></path></svg>

+ 1 - 0
src/pages/editor/icon/group-video.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024"><path d="M981.184 160.096C837.568 139.456 678.848 128 512 128S186.432 139.456 42.816 160.096C15.296 267.808 0 386.848 0 512s15.264 244.16 42.816 351.904C186.464 884.544 345.152 896 512 896s325.568-11.456 469.184-32.096C1008.704 756.192 1024 637.152 1024 512s-15.264-244.16-42.816-351.904zM384 704V320l320 192-320 192z"></path></svg>

+ 1 - 0
src/pages/editor/icon/preview.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 1024 1024"><path d="M977.824 454.016C826.432 280.288 672.512 192 516.128 192c-156.384 0-310.304 88.288-461.696 262.016a112 112 0 0 0-1.632 145.248c138.24 165.984 293.088 250.24 463.328 250.24s325.088-84.256 463.328-250.24a112 112 0 0 0-1.632-145.248z m-47.552 104.32c-126.848 152.32-264.48 227.2-414.144 227.2s-287.296-74.88-414.144-227.2a48 48 0 0 1 0.704-62.24C243.008 335.072 380.8 256 516.128 256s273.152 79.072 413.44 240.064c15.488 17.792 15.776 44.16 0.704 62.272z" p-id="4755"></path><path d="M512 288a224 224 0 1 0 0 448 224 224 0 0 0 0-448z m-64 224a64 64 0 1 1 0.032-128.032A64 64 0 0 1 448 512z" p-id="4756"></path></svg>

+ 6 - 0
src/pages/editor/index.ts

@@ -0,0 +1,6 @@
+import __index_vue from './index.vue';
+import __preview_vue from './preview.vue';
+
+export type * from './types';
+export { isEditorInstance } from './tools';
+export { __index_vue as Editor, __preview_vue as EditorPreview };

+ 104 - 0
src/pages/editor/index.vue

@@ -0,0 +1,104 @@
+<script setup lang="ts">
+import './style/index.scss';
+import { message, notification } from 'ant-design-vue';
+import type { NotificationArgsProps } from 'ant-design-vue/es/notification';
+import type { MessageArgsProps } from 'ant-design-vue/es/message';
+import type { EditorEmits, EditorInstance, EditorProps } from './types';
+import { useEditor } from './useEditor';
+import Preview from './preview.vue';
+
+defineOptions({
+  name: 'EditorContainer',
+});
+
+const props = defineProps<EditorProps>();
+const emits = defineEmits<EditorEmits>();
+
+const lastText = ref('');
+const lastContent = ref('');
+const [model, modifiers] = defineModel<string, 'html' | 'text'>('content', {
+  set(value) {
+    return modifiers.text ? lastText : value;
+  },
+});
+
+const container = useTemplateRef('container');
+const preview = useTemplateRef('preview');
+
+const instance = useEditor(container, props, {
+  onCreated(editor) {
+    emits('created', editor);
+    monitor(editor);
+  },
+  onChange(editor, content) {
+    emits('change', editor, content);
+    lastText.value = content.text;
+    lastContent.value = content.html;
+    model.value = content.html;
+  },
+});
+
+watchEffect(() => {
+  if (modifiers.text) return;
+  const value = model.value;
+  if (instance.editor.value && value != null && value !== unref(lastContent)) {
+    instance.editor.value.setHtml(value ?? '');
+  }
+});
+
+const n = (type: 'info' | 'success' | 'warning' | 'error', payload: NotificationArgsProps) => notification[type]({
+  ...payload,
+  getContainer: () => (container.value as HTMLElement) ?? document.body,
+});
+const m = (payload: MessageArgsProps) => message[payload.type ?? 'info'](payload);
+
+function monitor(editor: EditorInstance) {
+  editor.on('upload:progress', (event) => n('info', event));
+  editor.on('upload:success', (event) => n('success', event));
+  editor.on('upload:failed', (event) => n('error', event));
+  editor.on('message', (event) => m(event));
+  if (props.previewable) editor.on('preview', () => { preview.value?.toggle() });
+}
+
+defineExpose(instance);
+</script>
+
+<template>
+  <div class="editor-container editor-generate-preview-wrapper" ref="container">
+    <preview ref="preview" v-if="previewable" :content="lastContent">
+      <template #preview-header><slot name="preview-header"></slot></template>
+      <template #preview-footer><slot name="preview-footer"></slot></template>
+    </preview>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.editor-container {
+  display: flex;
+  flex-direction: column-reverse !important;
+  height: 100%;
+  &.editor-generate-preview-wrapper {
+    padding: 0 0 1em;
+  }
+  &.w-e-full-screen-container {
+    background-color: #fff;
+  }
+  &:not(.w-e-full-screen-container) {
+    max-height: var(--height, 100%);
+    position: relative;
+  }
+  overflow: hidden;
+
+  :deep(.ant-notification) {
+    top: 40px !important;
+    position: absolute !important;
+  }
+}
+:deep(.toolbar-container) {
+  flex: none;
+}
+:deep(.content-container) {
+  flex: auto;
+  overflow-y: auto;
+}
+</style>

+ 42 - 0
src/pages/editor/menus/delete-video.ts

@@ -0,0 +1,42 @@
+import { DomEditor, type IButtonMenu, type IDomEditor } from '@wangeditor/core';
+import { SlateTransforms } from '@wangeditor/editor';
+import deleteRaw from '../icon/delete.svg?raw';
+
+class DeleteVideoButtonMenu implements IButtonMenu {
+  readonly title = '删除视频';
+  readonly iconSvg = deleteRaw;
+  readonly tag = 'button';
+
+  getValue(editor: IDomEditor): string | boolean {
+    // 无需获取 val
+    return '';
+  }
+
+  isActive(editor: IDomEditor): boolean {
+    // 无需 active
+    return false;
+  }
+
+  isDisabled(editor: IDomEditor): boolean {
+    if (editor.selection == null) return true;
+
+    const videoNode = DomEditor.getSelectedNodeByType(editor, 'video');
+    return videoNode == null;
+  }
+
+  exec(editor: IDomEditor, value: string | boolean) {
+    if (this.isDisabled(editor)) return;
+
+    // 删除视频
+    SlateTransforms.removeNodes(editor, {
+      match: (n) => DomEditor.checkNodeType(n, 'video'),
+    });
+  }
+}
+
+export default {
+  key: 'deleteVideo',
+  factory(): DeleteVideoButtonMenu {
+    return new DeleteVideoButtonMenu();
+  },
+};

+ 2 - 0
src/pages/editor/menus/index.ts

@@ -0,0 +1,2 @@
+export { default as deleteVideoMenu } from './delete-video';
+export { default as previewMenu } from './preview';

+ 34 - 0
src/pages/editor/menus/preview.ts

@@ -0,0 +1,34 @@
+import type { IButtonMenu, IDomEditor } from '@wangeditor/core';
+import previewRaw from '../icon/preview.svg?raw';
+
+class PreviewButtonMenu implements IButtonMenu {
+  readonly title = '预览';
+  readonly iconSvg = previewRaw;
+  readonly tag = 'button';
+
+  getValue(editor: IDomEditor): string | boolean {
+    // 无需获取 val
+    return '';
+  }
+
+  isActive(editor: IDomEditor): boolean {
+    // 无需 active
+    return false;
+  }
+
+  isDisabled(editor: IDomEditor): boolean {
+    return editor.getText().trim().length === 0;
+  }
+
+  exec(editor: IDomEditor, value: string | boolean) {
+    if (this.isDisabled(editor)) return;
+    editor.emit('preview', { mode: 'editor' });
+  }
+}
+
+export default {
+  key: 'preview',
+  factory(): PreviewButtonMenu {
+    return new PreviewButtonMenu();
+  },
+};

+ 3 - 0
src/pages/editor/module/index.ts

@@ -0,0 +1,3 @@
+import ctrlEnterModule from '@wangeditor/plugin-ctrl-enter';
+import markdownModule from '@wangeditor/plugin-md';
+export { ctrlEnterModule, markdownModule };

+ 140 - 0
src/pages/editor/preview.vue

@@ -0,0 +1,140 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+import type { VxeModalInstance } from 'vxe-pc-ui';
+
+type OptionalNumber = number | void;
+
+interface Props {
+  title?: string;
+  content?: string;
+  offset?: number;
+}
+
+const { title = '预览', offset = 60 } = defineProps<Props>();
+
+const showPopup = ref(false);
+
+const openEvent = () => {
+  showPopup.value = true;
+  if (modal.value?.isMinimized()) modal.value?.revert();
+};
+const modal = useTemplateRef<VxeModalInstance>('modal');
+const modalProps = reactive({
+  className: `editor-preview-wrapper`,
+  lockView: false,
+  lockScroll: false,
+  mask: false,
+  escClosable: true,
+  showMinimize: true,
+  zoomConfig: {
+    minimizeOffsetMethod(params) {
+      console.log(params, 'log:minimizeOffsetMethod');
+      return {
+        top: 0,
+      };
+    },
+  },
+  width: void 0 as OptionalNumber,
+  height: void 0 as OptionalNumber,
+  position: { top: void 0 as OptionalNumber, left: void 0 as OptionalNumber },
+});
+
+const device = shallowRef({ width: 430, height: 932, offsetX: 40, offsetY: 40 });
+const deviceStyle = computed(() => {
+  return {
+    '--width': device.value.width,
+    '--height': device.value.height,
+    '--offsetX': device.value.offsetX,
+    '--offsetY': device.value.offsetY,
+    '--offsetTop': 60,
+    '--offsetBottom': 40,
+  };
+});
+watchEffect(() => {
+  const { width, height, offsetX, offsetY } = device.value;
+  const _width = width + offsetX * 2;
+  const _height = height + offsetY * 2;
+  const isFull = _height + 40 > window.innerHeight;
+  modalProps.width = _width + /* 边框 1px * 2 */ 2;
+  modalProps.height = isFull ? window.innerHeight - offset : void 0;
+  modalProps.position.top = isFull ? offset : Math.max(0, Math.min(offset, window.innerHeight - (_height + 47.5)));
+  modalProps.position.left = window.innerWidth - _width;
+  modalProps.className = `editor-preview-wrapper ${isFull ? 'is-full' : ''}`;
+});
+const toggle = (open?: boolean) => {
+  if (open) showPopup.value = open;
+  else if (modal.value?.isMinimized()) modal.value?.revert();
+  else showPopup.value = !showPopup.value;
+};
+
+defineExpose({ toggle });
+</script>
+
+<template>
+  <vxe-modal ref="modal" v-model="showPopup" :title="title" v-bind="modalProps as any">
+    <template #title="{ minimized }">
+      <div v-if="minimized">预览</div>
+      <div v-else>{{ title }}</div>
+    </template>
+    <template #default>
+      <div :style="deviceStyle">
+        <img class="mock-wrapper" src="./assets/iphone15-pro-max.png" alt="" />
+        <div class="content-wrapper editor-generate-preview-wrapper">
+          <slot name="preview-header" class="header"></slot>
+          <slot name="preview-content" class="content w-e-text-container" :content>
+            <div class="content w-e-text-container" v-html="content"></div>
+          </slot>
+          <slot name="preview-footer" class="footer"></slot>
+        </div>
+      </div>
+    </template>
+  </vxe-modal>
+  <slot :toggle="toggle"></slot>
+</template>
+<style lang="scss">
+.editor-preview-wrapper {
+  &:not(.is-full) {
+    .vxe-modal--box {
+      height: auto !important;
+    }
+  }
+  .vxe-modal--body-default {
+    padding: 0 !important;
+  }
+  .vxe-modal--content {
+    overflow-x: hidden !important;
+    overflow-y: auto !important;
+    > div {
+      position: relative;
+      width: calc(var(--width) * 1px + var(--offsetX) * 2px);
+      height: calc(var(--height) * 1px + var(--offsetY) * 2px);
+    }
+  }
+}
+</style>
+<style scoped lang="scss">
+.mock-wrapper {
+  position: absolute;
+  z-index: 2;
+  width: 100%;
+  height: 100%;
+  object-fit: scale-down;
+  pointer-events: none;
+}
+.content-wrapper {
+  position: absolute;
+  margin: 40px;
+  top: -0.5px;
+  left: -0.5px;
+  padding-top: calc(var(--offsetTop) * 1px);
+  padding-bottom: calc(var(--offsetBottom) * 1px);
+  max-width: calc(var(--width) * 1px);
+  max-height: calc(var(--height) * 1px);
+  border-radius: 40px;
+  overflow: auto;
+  &::-webkit-scrollbar {
+    display: none; /* 隐藏滚动条 */
+  }
+  //background-color: rgba(255, 0, 0, 0.67);
+}
+</style>

+ 89 - 0
src/pages/editor/style/editor-content.scss

@@ -0,0 +1,89 @@
+.w-e-text-container {
+  [data-slate-editor] {
+    > *:first-child {
+      margin-top: 0;
+    }
+
+    .w-e-image-container {
+      margin: 0 3px;
+      display: inline-block;
+
+      &:hover {
+        box-shadow: 0 0 0 2px var(--w-e-textarea-selected-border-color)
+      }
+    }
+
+    .w-e-selected-image-container {
+      position: relative;
+      overflow: hidden;
+
+      &:hover {
+        box-shadow: none;
+      }
+
+      .w-e-image-dragger {
+        position: absolute;
+        width: 7px;
+        height: 7px;
+        background-color: var(--w-e-textarea-handler-bg-color);
+      }
+
+      .left-top {
+        top: 0;
+        left: 0;
+        cursor: nwse-resize;
+      }
+
+      .right-top {
+        top: 0;
+        right: 0;
+        cursor: nesw-resize;
+      }
+
+      .left-bottom {
+        bottom: 0;
+        left: 0;
+        cursor: nesw-resize;
+      }
+
+      .right-bottom {
+        right: 0;
+        bottom: 0;
+        cursor: nwse-resize;
+      }
+    }
+  }
+
+  [contenteditable=false] {
+    .w-e-image-container {
+      &:hover {
+        box-shadow: none
+      }
+    }
+  }
+
+  .table-container {
+    border: 1px dashed var(--w-e-textarea-border-color);
+    border-radius: 5px;
+    margin-top: 10px;
+    overflow-x: auto;
+    padding: 10px;
+    width: 100%
+  }
+}
+
+.w-e-textarea-divider {
+  border-radius: 3px;
+  padding: 0.5em;
+}
+
+.w-e-textarea-video-container {
+  background-image: linear-gradient(45deg, #eee 25%, transparent 0, transparent 75%, #eee 0, #eee), linear-gradient(45deg, #eee 25%, #fff 0, #fff 75%, #eee 0, #eee);
+  background-position: 0 0, 10px 10px;
+  background-size: 20px 20px;
+  border: 1px dashed var(--w-e-textarea-border-color);
+  border-radius: 5px;
+  margin: 10px auto 0;
+  padding: 10px 0;
+  text-align: center
+}

+ 403 - 0
src/pages/editor/style/editor-toolbar.scss

@@ -0,0 +1,403 @@
+.w-e-toolbar * {
+  margin: 0;
+  padding: 0;
+  outline: none;
+  line-height: 1.5;
+  box-sizing: border-box;
+}
+
+.w-e-bar {
+  background-color: var(--w-e-toolbar-bg-color);
+  color: var(--w-e-toolbar-color);
+  font-size: 14px;
+  padding: 0 5px
+}
+
+.w-e-bar svg {
+  fill: var(--w-e-toolbar-color);
+  height: 14px;
+  width: 14px
+}
+
+.w-e-bar-show {
+  display: flex
+}
+
+.w-e-bar-hidden {
+  display: none
+}
+
+.w-e-hover-bar {
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px;
+  box-shadow: 0 2px 5px #0000001f;
+  position: absolute
+}
+
+.w-e-toolbar {
+  flex-wrap: wrap;
+  position: relative
+}
+
+.w-e-bar-divider {
+  background-color: var(--w-e-toolbar-border-color);
+  display: inline-flex;
+  height: 40px;
+  margin: 0 5px;
+  width: 1px
+}
+
+.w-e-bar-item {
+  display: flex;
+  height: 40px;
+  padding: 4px;
+  position: relative;
+  text-align: center
+}
+
+.w-e-bar-item, .w-e-bar-item button {
+  align-items: center;
+  justify-content: center
+}
+
+.w-e-bar-item button {
+  background: transparent;
+  border: none;
+  color: var(--w-e-toolbar-color);
+  cursor: pointer;
+  display: inline-flex;
+  height: 32px;
+  overflow: hidden;
+  padding: 0 8px;
+  white-space: nowrap
+}
+
+.w-e-bar-item button:hover {
+  background-color: var(--w-e-toolbar-active-bg-color);
+  color: var(--w-e-toolbar-active-color)
+}
+
+.w-e-bar-item button .title {
+  margin-left: 5px
+}
+
+.w-e-bar-item .active {
+  background-color: var(--w-e-toolbar-active-bg-color);
+  color: var(--w-e-toolbar-active-color)
+}
+
+.w-e-bar-item .disabled {
+  color: var(--w-e-toolbar-disabled-color);
+  cursor: not-allowed
+}
+
+.w-e-bar-item .disabled svg {
+  fill: var(--w-e-toolbar-disabled-color)
+}
+
+.w-e-bar-item .disabled:hover {
+  background-color: var(--w-e-toolbar-bg-color);
+  color: var(--w-e-toolbar-disabled-color)
+}
+
+.w-e-bar-item .disabled:hover svg {
+  fill: var(--w-e-toolbar-disabled-color)
+}
+
+.w-e-menu-tooltip-v5:before {
+  background-color: var(--w-e-toolbar-active-color);
+  border-radius: 5px;
+  color: var(--w-e-toolbar-bg-color);
+  content: attr(data-tooltip);
+  font-size: .75em;
+  opacity: 0;
+  padding: 5px 10px;
+  position: absolute;
+  text-align: center;
+  top: 40px;
+  transition: opacity .6s;
+  visibility: hidden;
+  white-space: pre;
+  z-index: 1
+}
+
+.w-e-menu-tooltip-v5:after {
+  border: 5px solid transparent;
+  border-bottom: 5px solid var(--w-e-toolbar-active-color);
+  content: "";
+  opacity: 0;
+  position: absolute;
+  top: 30px;
+  transition: opacity .6s;
+  visibility: hidden
+}
+
+.w-e-menu-tooltip-v5:hover:after, .w-e-menu-tooltip-v5:hover:before {
+  opacity: 1;
+  visibility: visible
+}
+
+.w-e-menu-tooltip-v5.tooltip-right:before {
+  left: 100%;
+  top: 10px
+}
+
+.w-e-menu-tooltip-v5.tooltip-right:after {
+  border-bottom-color: transparent;
+  border-left-color: transparent;
+  border-right-color: var(--w-e-toolbar-active-color);
+  border-top-color: transparent;
+  left: 100%;
+  margin-left: -10px;
+  top: 16px
+}
+
+.w-e-bar-item-group .w-e-bar-item-menus-container {
+  background-color: var(--w-e-toolbar-bg-color);
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px;
+  box-shadow: 0 2px 10px #0000001f;
+  display: none;
+  left: 0;
+  margin-top: 40px;
+  position: absolute;
+  top: 0;
+  z-index: 1
+}
+
+.w-e-bar-item-group:hover .w-e-bar-item-menus-container {
+  display: block
+}
+
+.w-e-select-list {
+  background-color: var(--w-e-toolbar-bg-color);
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px;
+  box-shadow: 0 2px 10px #0000001f;
+  left: 0;
+  margin-top: 40px;
+  max-height: 350px;
+  min-width: 100px;
+  overflow-y: auto;
+  position: absolute;
+  top: 0;
+  z-index: 1
+}
+
+.w-e-select-list ul {
+  line-height: 1;
+  list-style: none
+}
+
+.w-e-select-list ul .selected {
+  background-color: var(--w-e-toolbar-active-bg-color)
+}
+
+.w-e-select-list ul li {
+  cursor: pointer;
+  padding: 7px 0 7px 25px;
+  position: relative;
+  text-align: left;
+  white-space: nowrap
+}
+
+.w-e-select-list ul li:hover {
+  background-color: var(--w-e-toolbar-active-bg-color)
+}
+
+.w-e-select-list ul li svg {
+  left: 0;
+  margin-left: 5px;
+  margin-top: -7px;
+  position: absolute;
+  top: 50%
+}
+
+.w-e-bar-bottom .w-e-select-list {
+  bottom: 0;
+  margin-bottom: 40px;
+  margin-top: 0;
+  top: inherit
+}
+
+.w-e-drop-panel {
+  background-color: var(--w-e-toolbar-bg-color);
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px;
+  box-shadow: 0 2px 10px #0000001f;
+  margin-top: 40px;
+  min-width: 200px;
+  padding: 10px;
+  position: absolute;
+  top: 0;
+  z-index: 1
+}
+
+.w-e-bar-bottom .w-e-drop-panel {
+  bottom: 0;
+  margin-bottom: 40px;
+  margin-top: 0;
+  top: inherit
+}
+
+.w-e-modal {
+  background-color: var(--w-e-toolbar-bg-color);
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px;
+  box-shadow: 0 2px 10px #0000001f;
+  color: var(--w-e-toolbar-color);
+  font-size: 14px;
+  min-height: 40px;
+  min-width: 100px;
+  padding: 20px 15px 0;
+  position: absolute;
+  text-align: left;
+  z-index: 1
+}
+
+.w-e-modal .btn-close {
+  cursor: pointer;
+  line-height: 1;
+  padding: 5px;
+  position: absolute;
+  right: 8px;
+  top: 7px
+}
+
+.w-e-modal .btn-close svg {
+  fill: var(--w-e-toolbar-color);
+  height: 10px;
+  width: 10px
+}
+
+.w-e-modal .babel-container {
+  display: block;
+  margin-bottom: 15px
+}
+
+.w-e-modal .babel-container span {
+  display: block;
+  margin-bottom: 10px
+}
+
+.w-e-modal .button-container {
+  margin-bottom: 15px
+}
+
+.w-e-modal button {
+  background-color: var(--w-e-modal-button-bg-color);
+  border: 1px solid var(--w-e-modal-button-border-color);
+  border-radius: 4px;
+  color: var(--w-e-toolbar-color);
+  cursor: pointer;
+  font-weight: 400;
+  height: 32px;
+  padding: 4.5px 15px;
+  text-align: center;
+  touch-action: manipulation;
+  transition: all .3s cubic-bezier(.645, .045, .355, 1);
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  user-select: none;
+  white-space: nowrap
+}
+
+.w-e-modal input[type=number], .w-e-modal input[type=text], .w-e-modal textarea {
+  font-feature-settings: "tnum";
+  background-color: var(--w-e-toolbar-bg-color);
+  border: 1px solid var(--w-e-modal-button-border-color);
+  border-radius: 4px;
+  color: var(--w-e-toolbar-color);
+  font-variant: tabular-nums;
+  padding: 4.5px 11px;
+  transition: all .3s;
+  width: 100%
+}
+
+.w-e-modal textarea {
+  min-height: 60px
+}
+
+body .w-e-modal, body .w-e-modal * {
+  box-sizing: border-box
+}
+
+
+.w-e-panel-content-color {
+  list-style: none;
+  text-align: left;
+  width: 230px
+}
+
+.w-e-panel-content-color li {
+  border: 1px solid var(--w-e-toolbar-bg-color);
+  border-radius: 3px 3px;
+  cursor: pointer;
+  display: inline-block;
+  padding: 2px
+}
+
+.w-e-panel-content-color li:hover {
+  border-color: var(--w-e-toolbar-color)
+}
+
+.w-e-panel-content-color li .color-block {
+  border: 1px solid var(--w-e-toolbar-border-color);
+  border-radius: 3px 3px;
+  height: 17px;
+  width: 17px
+}
+
+.w-e-panel-content-color .active {
+  border-color: var(--w-e-toolbar-color)
+}
+
+.w-e-panel-content-color .clear {
+  line-height: 1.5;
+  margin-bottom: 5px;
+  width: 100%
+}
+
+.w-e-panel-content-color .clear svg {
+  height: 16px;
+  margin-bottom: -4px;
+  width: 16px
+}
+
+.w-e-panel-content-emotion {
+  font-size: 20px;
+  list-style: none;
+  text-align: left;
+  width: 300px
+}
+
+.w-e-panel-content-emotion li {
+  border-radius: 3px 3px;
+  cursor: pointer;
+  display: inline-block;
+  padding: 0 5px
+}
+
+.w-e-panel-content-emotion li:hover {
+  background-color: var(--w-e-textarea-slight-bg-color)
+}
+
+.w-e-panel-content-table {
+  background-color: var(--w-e-toolbar-bg-color)
+}
+
+.w-e-panel-content-table table {
+  border-collapse: collapse
+}
+
+.w-e-panel-content-table td {
+  border: 1px solid var(--w-e-toolbar-border-color);
+  cursor: pointer;
+  height: 15px;
+  padding: 3px 5px;
+  width: 20px
+}
+
+.w-e-panel-content-table td.active {
+  background-color: var(--w-e-toolbar-active-bg-color)
+}

+ 85 - 0
src/pages/editor/style/index.scss

@@ -0,0 +1,85 @@
+//@use "/editor/preview.css";
+@use "editor-toolbar";
+@use "editor-content";
+
+:root,
+:host {
+  --w-e-toolbar-color: #595959;
+  --w-e-toolbar-bg-color: #fff;
+  --w-e-toolbar-active-color: #333;
+  --w-e-toolbar-active-bg-color: #f1f1f1;
+  --w-e-toolbar-disabled-color: #999;
+  --w-e-toolbar-border-color: #e8e8e8;
+  --w-e-modal-button-bg-color: #fafafa;
+  --w-e-modal-button-border-color: #d9d9d9;
+}
+
+
+.w-e-text-container {
+  .w-e-scroll {
+    -webkit-overflow-scrolling: touch;
+    height: 100%
+  }
+
+  [data-slate-editor] {
+    padding: 0 1em;
+    outline: 0;
+    min-height: 100%;
+    word-wrap: break-word;
+    white-space: pre-wrap;
+    border-top: 1px solid transparent;
+
+    [data-selected=true] {
+      box-shadow: 0 0 0 2px var(--w-e-textarea-selected-border-color)
+    }
+  }
+}
+
+
+.w-e-text-placeholder {
+  position: absolute;
+  left: 10px;
+  top: 17px;
+  width: 90%;
+  font-style: italic;
+  color: var(--w-e-textarea-slight-color);
+  pointer-events: none;
+  user-select: none
+}
+
+.w-e-progress-bar {
+  background-color: var(--w-e-textarea-handler-bg-color);
+  height: 1px;
+  position: absolute;
+  transition: width .3s;
+  width: 0
+}
+
+.w-e-full-screen-container {
+  position: fixed;
+  top: 0 !important;
+  right: 0 !important;
+  bottom: 0 !important;
+  left: 0 !important;
+  display: flex !important;
+  flex-direction: column !important;
+  margin: 0 !important;
+  padding: 0 !important;
+  width: 100% !important;
+  height: 100% !important;
+
+  [data-w-e-textarea=true] {
+    flex: 1 !important
+  }
+}
+
+.w-e-max-length-info {
+  bottom: .5em;
+  right: 1em;
+  color: var(--w-e-textarea-slight-color);
+  pointer-events: none;
+  position: absolute;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  user-select: none
+}

+ 107 - 0
src/pages/editor/tools.ts

@@ -0,0 +1,107 @@
+import { Boot } from '@wangeditor/editor';
+import type { EditorInstance, EditorUploadProps } from './types';
+import { ctrlEnterModule, markdownModule } from './module';
+import { deleteVideoMenu, previewMenu } from './menus';
+
+export function upload(this: EditorInstance, props: EditorUploadProps) {
+  const accept = props.accept?.split(',').map((v) => v.trim()) ?? [];
+  if (accept.includes('*/*')) accept.length = 0;
+
+  const maxFileSize = props.maxFileSize;
+  const minFileSize = props.minFileSize;
+
+  return {
+    customUpload: async (file: File, insertFn: (url: string, altOrPoster: string) => void) => {
+      const isVideo = file.type.startsWith('video');
+      const progress = (value: number, event?: { loaded: number; total: number }) => {
+        this.showProgressBar(value);
+        const total = event?.total ?? file.size;
+        const loaded = event?.loaded ?? (value ? total : 0);
+        this.emit('upload:progress', {
+          key: `upload-${file.name}`,
+          message: value ? `${file.name} 文件已上传:${value}%` : `${file.name} 文件:开始上传 `,
+          description: `请等待上传完成在操作编辑器`,
+          total,
+          loaded,
+          progress: value,
+        });
+      };
+      const success = (data: { url: string; name?: string; poster?: string }) => {
+        this.emit('upload:success', {
+          key: `upload-${file.name}`,
+          message: `${file.name} 文件:上传成功`,
+          description: `请操作编辑器`,
+          ...data,
+        });
+        this.alert(`上传成功`, 'success');
+      };
+      const failed = (error: { message: string }) => {
+        this.emit('upload:failed', {
+          key: `upload-${file.name}`,
+          message: `${file.name} 文件:上传失败`,
+          description: error.message,
+        });
+        this.alert(`上传失败(${error.message})`, 'error');
+      };
+      let off: any;
+      try {
+        if (props.onBeforeUpload) props.onBeforeUpload?.call(this, file, props);
+        else {
+          const type = file.type;
+          if (/*type && */ accept.length && !(accept.includes(type) || accept.includes(type.split('/')[0] + '/*'))) {
+            throw { message: `文件类型(${type || '未知'})不允许, 仅允许: ${accept.join('; ')}` };
+          }
+
+          const size = file.size;
+          if (size && minFileSize && size < minFileSize) throw { message: `${file.name} 文件太小` };
+          if (size && maxFileSize && size > maxFileSize) throw { message: `${file.name} 文件太大` };
+        }
+        const method = props.handle(file);
+        off = method.onUpload((event) => {
+          const decimal = 10 ** 2;
+          const value = Math.floor((event.loaded * 100 * decimal) / event.total) / decimal;
+          progress?.(Math.min(value, 99), event);
+        });
+        progress(0);
+        const { url, name = file.name, poster = '' } = await method;
+        insertFn(url, isVideo ? poster : name);
+        progress(100);
+        success({ url, name, poster });
+      } catch (error: any) {
+        failed(error);
+      }
+      off?.();
+    },
+  };
+}
+
+export function getResources(this: EditorInstance, type?: 'image' | 'video'): string[] {
+  if (type === 'image') return this.getElemsByType('image').map((item: any) => item.src);
+  if (type === 'video') return this.getElemsByType('video').flatMap((item: any) => [item.src, item.poster]);
+  return [...this.getElemsByType('image'), ...this.getElemsByType('video')].flatMap((item: any) => [item.src, item.poster]).filter(Boolean);
+}
+
+let booted = false;
+export function register() {
+  if (booted) return;
+  Boot.registerModule(markdownModule);
+  // Boot.registerModule(ctrlEnterModule);
+  Boot.registerMenu(previewMenu);
+  Boot.registerMenu(deleteVideoMenu);
+  booted = true;
+}
+
+export function isEditorInstance(instance: unknown): instance is EditorInstance {
+  return instance != null && typeof instance === 'object' && typeof (instance as any)['getHtml'] === 'function';
+}
+
+export function getElement(parent: HTMLElement, selector: `#${string}` | `.${string}`): HTMLElement {
+  let element = parent.querySelector<HTMLElement>(selector);
+  if (!element) {
+    element = document.createElement('div');
+    if (selector.startsWith('#')) element.id = selector.slice(1);
+    else if (selector.startsWith('.')) element.classList.add(selector.slice(1));
+    parent.appendChild(element);
+  }
+  return element;
+}

+ 59 - 0
src/pages/editor/types.ts

@@ -0,0 +1,59 @@
+import type { IDomEditor, Toolbar } from '@wangeditor/core';
+import type { SlateElement } from '@wangeditor/editor';
+import { toolbar } from './config';
+
+type ToolbarKey = (typeof toolbar)[number];
+
+export interface EditorProps {
+  placeholder?: string;
+  disable?: boolean;
+  defaultValue?: string;
+
+  previewable?: boolean;
+
+  uploadPicture?: EditorUploadProps<'image/*' | `image/${string}`>;
+  uploadVideo?: EditorUploadProps<'video/*' | `video/${string}`>;
+
+  toolbar?: Array<
+    | ToolbarKey
+    | '|'
+    | {
+        key: `group-${string}`;
+        title: string;
+        iconSvg: string;
+        menuKeys: ToolbarKey[];
+      }
+  >;
+}
+
+export interface EditorEmits {
+  created: [editor: IDomEditor];
+  change: [editor: IDomEditor, content: { html: string; text: string }];
+}
+
+export type EditorInstance = IDomEditor;
+export type ToolbarInstance = Toolbar;
+
+export interface EditorUploadProps<Accept extends string = string> {
+  accept: Accept;
+  handle: EditorUploadHandle;
+  onBeforeUpload?: (this: EditorInstance, file: File, props: Pick<EditorUploadProps, 'accept' | 'minFileSize' | 'maxFileSize'>) => void;
+  maxFileSize?: number;
+  minFileSize?: number;
+}
+
+export interface EditorUploadHandle {
+  (file: File): Promise<{ url: string; name?: string; poster?: string }> & { onUpload(callback: (event: { loaded: number; total: number }) => void): () => void };
+}
+
+export type EditorImageElement = SlateElement & {
+  src: string;
+  alt: string;
+  url?: string;
+  href?: string;
+};
+
+export type EditorVideoElement = SlateElement & {
+  src: string;
+  poster?: string;
+};

+ 136 - 0
src/pages/editor/useEditor.ts

@@ -0,0 +1,136 @@
+import { type MaybeComputedElementRef, tryOnMounted, tryOnUnmounted, unrefElement, useElementSize } from '@vueuse/core';
+import { createEditor, createToolbar } from '@wangeditor/editor';
+import { getDefaultToolbarConfig } from './config';
+import { getElement, getResources, isEditorInstance, register, upload } from './tools';
+import type { EditorEmits, EditorImageElement, EditorInstance, EditorProps, EditorVideoElement, ToolbarInstance } from './types';
+
+export type EmitsToProps<T extends Record<string, any>> = {
+  [K in keyof T as `on${Capitalize<string & K>}`]?: T[K] extends [infer U] ? (event: U) => void : T[K] extends [...infer Args] ? (...args: Args) => void : never;
+};
+
+export function useEditor(target: MaybeComputedElementRef, props?: EditorProps, emits?: EmitsToProps<EditorEmits>) {
+  register();
+
+  const editor = shallowRef<EditorInstance>();
+  const toolbar = shallowRef<ToolbarInstance>();
+
+  const handle1 = useElementSize(target, { height: 0, width: 0 });
+  const handle2 = watch(
+    [editor, () => props?.defaultValue],
+    ([editor, html]) => {
+      if (!isEditorInstance(editor)) return;
+      const value = toValue(html);
+      if (value) editor.dangerouslyInsertHtml(value);
+    },
+    { immediate: true }
+  );
+  const handle3 = watch(
+    [editor, () => props?.disable],
+    ([editor, disable = false]) => {
+      if (!isEditorInstance(editor)) return;
+      if (disable) editor.disable();
+      else editor.enable();
+    },
+    { immediate: true }
+  );
+
+  const stop = () => {
+    handle1.stop();
+    handle2.stop();
+    handle3.stop();
+  };
+
+  const resources = new Set<string>();
+
+  tryOnMounted(() => {
+    const container = unrefElement(target);
+    if (container) {
+      const height = handle1.height.value;
+      if (container && height > 0 && /* 全屏 */ height < window.innerHeight) container.style.setProperty('--height', `${height}px`);
+
+      const querySelector = getElement.bind(null, container as HTMLElement);
+      editor.value = createEditor({
+        selector: querySelector('.content-container')!,
+        html: toValue(props?.defaultValue ?? ''),
+        config: {
+          placeholder: toValue(props?.placeholder ?? '请输入内容...'),
+          scroll: false,
+          MENU_CONF: {
+            insertImage: {
+              onInsertedImage(node: EditorImageElement | null) {
+                if (node?.src) resources.add(node.src);
+              },
+            },
+            insertVideo: {
+              onInsertedVideo(node: EditorVideoElement | null) {
+                if (node?.src) resources.add(node.src);
+              },
+            },
+            deleteVideo: {},
+          },
+          hoverbarKeys: {
+            text: {
+              menuKeys: ['headerSelect', '|', 'bold', 'italic', 'underline', 'through', 'sup', 'sub', 'color', 'bgColor', 'clearStyle'],
+            },
+            video: {
+              menuKeys: ['enter', 'editVideoSize', 'deleteVideo'],
+            },
+          },
+          onCreated(editor) {
+            emits?.onCreated?.call(editor, editor);
+          },
+          onChange(editor) {
+            const text = editor.getText();
+            const html = text ? editor.getHtml() : '';
+            emits?.onChange?.call(editor, editor, { html, text });
+          },
+          customAlert(message, type) {
+            editor.value?.emit('message', { type, content: message });
+          },
+        },
+      });
+      toolbar.value = createToolbar({
+        selector: querySelector('.toolbar-container')!,
+        editor: editor.value,
+        config: {
+          toolbarKeys: toValue(props?.toolbar) ?? getDefaultToolbarConfig(),
+          excludeKeys: [],
+        },
+      });
+
+      if (props?.uploadPicture) Object.assign(editor.value.getMenuConfig('uploadImage'), upload.call(editor.value, props.uploadPicture));
+      else toolbar.value?.getConfig().excludeKeys?.push('uploadImage');
+
+      if (props?.uploadVideo) Object.assign(editor.value.getMenuConfig('uploadVideo'), upload.call(editor.value, props.uploadVideo));
+      else toolbar.value?.getConfig().excludeKeys?.push('uploadVideo');
+
+      if (!props?.previewable) toolbar.value?.getConfig().excludeKeys?.push('preview');
+    }
+  });
+  tryOnUnmounted(() => {
+    stop();
+    editor.value?.destroy();
+    toolbar.value = void 0;
+    editor.value = void 0;
+    resources.clear();
+  });
+
+  function save() {
+    if (!editor.value) throw { message: `编辑器未初始化` };
+
+    const text = editor.value.getText();
+    if (!text?.trim().length) throw { message: `编辑器内容为空` };
+
+    const lapsedResources: string[] = [];
+    if (resources.size) {
+      const value = getResources.call(editor.value);
+      resources.forEach((r) => {
+        if (!value.includes(r)) lapsedResources.push(r);
+      });
+    }
+    const html = editor.value?.getHtml();
+    return { text, html, lapsedResources };
+  }
+
+  return { editor, toolbar, save };
+}

+ 45 - 0
src/request/upload.ts

@@ -0,0 +1,45 @@
+import { createAlova } from 'alova';
+import VueHook from 'alova/vue';
+import { xhrRequestAdapter } from '@alova/adapter-xhr';
+import { getToken } from '@/request/tools';
+
+import { notification } from 'ant-design-vue';
+
+const request = createAlova({
+  baseURL: '/manager/',
+  statesHook: VueHook,
+  requestAdapter: xhrRequestAdapter(),
+  async beforeRequest(method) {
+    if (!method.config.meta?.ignoreToken) method.config.headers.Authorization ??= getToken();
+  },
+  responded: {
+    async onSuccess(response, method) {
+      try {
+        if (response.status >= 400) throw new Error(`${response.statusText}(${response.status})`);
+        const result = await response.data;
+        /* 接口修正 code */
+        if (result.success !== false && result.code === 200) result.code = 0;
+        const { error = false, warn = false, code = error || warn ? -1 : 0, msg: message = '未知错误', data } = result;
+        if (code === 0) return data;
+        throw new Error(`${message}(${code})`);
+      } catch (e: any) {
+        if (!method.meta?.ignoreException) {
+          notification.error({
+            message: method.url,
+            description: e?.message,
+            key: method.url,
+          });
+        }
+        throw e;
+      }
+    },
+  },
+});
+
+export default function upload(file: File, ignoreException = false) {
+  const formData = new FormData();
+  formData.append('file', file);
+  return request.Post<{ url: string; name: string }>(`/file/upload`, formData, {
+    meta: { ignoreException },
+  });
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов