瀏覽代碼

修改问题

快乐的梦鱼 3 月之前
父節點
當前提交
5d45ac37ed

+ 52 - 0
src/common/components/form/RichTextEditor.vue

@@ -0,0 +1,52 @@
+<template>
+  <view class="d-flex flex-col">
+    <Parse v-if="modelValue" :content="modelValue" />
+    <text v-else>未编写内容,点击编写</text>
+    <view class="d-flex flex-row align-center gap-s mt-3">
+      <Button @click="preview">预览内容</Button>
+      <Button @click="edit" type="primary">编辑内容</Button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { navTo } from '@imengyu/imengyu-utils/dist/uniapp/PageAction';
+import { onPageShow } from '@dcloudio/uni-app';
+import Parse from '@/components/display/parse/Parse.vue';
+import Button from '@/components/basic/Button.vue';
+
+const props = defineProps({	
+  modelValue: { 
+    type: String,
+    default: null 
+  },
+})
+const emit = defineEmits(['update:modelValue'])
+let editorOpened = false;
+
+function preview() {
+  uni.setStorage({
+    key: 'editorContent',
+    data: props.modelValue,
+    success: () => navTo('/pages/editor/preview'),
+  })
+}
+function edit() {
+  editorOpened = true;
+  uni.setStorage({
+    key: 'editorContent',
+    data: props.modelValue,
+    success: () => navTo('/pages/editor/editor'),
+  })
+}
+
+onPageShow(() => {
+  if (editorOpened) {
+    editorOpened = false;
+    uni.getStorage({
+      key: 'editorContent',
+      success: (success) => emit('update:modelValue', success.data),
+    })
+  }
+})
+</script>

+ 31 - 10
src/components/dynamic/DynamicFormControl.vue

@@ -110,31 +110,39 @@
         />
       </template>
 
-      <template v-else-if="formDefineItem.type === 'lonlat-picker'">
-        <LonlatPicker
+      <template v-else-if="formDefineItem.type === 'select-lonlat'">
+        <PickerLonlat
           ref="itemRef"
           :modelValue="modelValue"
           @update:modelValue="(v:any) =>{onValueChanged(v);formItemRef.onFieldChange(v)}"
           v-bind="params"
         />
       </template>
-      <template v-else-if="formDefineItem.type === 'richtext'">
-        <RichTextEditor
+      <template v-else-if="formDefineItem.type === 'picker-datetime'">
+        <DateTimePickerField
           ref="itemRef"
-          :modelValue="modelValue"
-          @update:modelValue="onValueChanged"
+          :value="modelValue"
           v-bind="params"
+          @update:modelValue="(e: any) => onValueChanged(e)"
         />
       </template>
-      <template v-else-if="formDefineItem.type === 'datetime-picker'">
-        <uni-datetime-picker
+      <template v-else-if="formDefineItem.type === 'picker-time'">
+        <TimePickerField
           ref="itemRef"
           :value="modelValue"
           v-bind="params"
-          @change="(e: any) => onValueChanged(e)"
+          @update:modelValue="(e: any) => onValueChanged(e)"
         />
       </template>
-      <template v-else-if="formDefineItem.type === 'image-uploader'">
+      <template v-else-if="formDefineItem.type === 'picker-date'">
+        <DatePickerField
+          ref="itemRef"
+          :value="modelValue"
+          v-bind="params"
+          @update:modelValue="(e: any) => onValueChanged(e)"
+        />
+      </template>
+      <template v-else-if="formDefineItem.type === 'uploader-image'">
         <ImageUploaderWrapper
           ref="itemRef"
           :value="modelValue"
@@ -151,6 +159,14 @@
           v-bind="params"
         />
       </template>
+      <template v-else-if="formDefineItem.type === 'richtext'">
+        <RichTextEditor
+          ref="itemRef"
+          :modelValue="modelValue"
+          @update:modelValue="onValueChanged"
+          v-bind="params"
+        />
+      </template>
       <template v-else>
         <text>Fallback: unknow form type {{ formDefineItem.type }}</text>
       </template>
@@ -175,6 +191,11 @@ import type { IDynamicFormItemSelectIdFormItemProps } from './wrappers/PickerIdF
 import type { CascaderProps } from '../form/Cascader.vue';
 import CascaderField, { type CascaderFieldProps } from '../form/CascaderField.vue';
 import PickerCityField from './wrappers/PickerCityField.vue';
+import PickerLonlat from './wrappers/PickerLonlat.vue';
+import DateTimePickerField from '../form/DateTimePickerField.vue';
+import TimePickerField from '../form/TimePickerField.vue';
+import DatePickerField from '../form/DatePickerField.vue';
+import RichTextEditor from '@/common/components/form/RichTextEditor.vue';
 
 const props = defineProps({	
   parentModel: {

+ 3 - 2
src/components/dynamic/wrappers/CheckBoxList.vue

@@ -1,5 +1,5 @@
 <template>
-  <view>
+  <FlexRow align="center" wrap>
     <ActivityIndicator v-if="loadStatus === 'loading'" />
     <Alert
       v-else-if="loadStatus === 'error'" 
@@ -23,7 +23,7 @@
         :disabled="value.disable"
       />
     </CheckBoxGroup>
-  </view>
+  </FlexRow>
 </template>
 
 <script setup lang="ts">
@@ -31,6 +31,7 @@ import ActivityIndicator from '@/components/basic/ActivityIndicator.vue';
 import Alert from '@/components/feedback/Alert.vue';
 import CheckBox from '@/components/form/CheckBox.vue';
 import CheckBoxGroup from '@/components/form/CheckBoxGroup.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
 import { onMounted, ref, type PropType } from 'vue';
 
 export interface CheckBoxListItem {

+ 11 - 0
src/components/dynamic/wrappers/PickerLonlat.ts

@@ -0,0 +1,11 @@
+
+export interface PickerLonlatProps {
+  /**
+   * 是否禁用
+   */
+  disabled?: boolean;
+  /**
+   * 默认经纬度
+   */
+  defaultLongLat?: number[];
+}

+ 32 - 0
src/components/dynamic/wrappers/PickerLonlat.vue

@@ -0,0 +1,32 @@
+<template>
+  <Button
+    type="primary"
+    size="small"
+    :text="props.modelValue ? `${props.modelValue.join(',')}` : '请选择经纬度'"
+    @click="openPicker"
+  />
+</template>
+
+<script setup lang="ts">
+import type { PickerLonlatProps } from './PickerLonlat';
+import Button from '@/components/basic/Button.vue';
+
+const props = defineProps<PickerLonlatProps & {
+  modelValue: number[],
+}>()
+
+const emit = defineEmits(['update:modelValue']);
+
+function openPicker() {
+  if (props.disabled)
+    return;
+  uni.chooseLocation({
+    latitude: props.modelValue?.[0] || props.defaultLongLat?.[0],
+    longitude: props.modelValue?.[1] || props.defaultLongLat?.[1],
+    success: (res) => {
+      emit('update:modelValue', [res.latitude, res.longitude]);
+    },
+  });
+}
+
+</script>

+ 4 - 0
src/components/form/Field.vue

@@ -4,6 +4,7 @@
     :pressedColor="themeContext.resolveThemeColor('FieldPressedColor', 'pressed.white')"
     :innerStyle="{ 
       ...themeStyles.field.value, 
+      ...(labelPosition === 'top' ? themeStyles.fieldVertical.value : {}), 
       ...fieldStyle, 
       ...(focused ? activeFieldStyle : {}),
       ...(error || finalErrorMessage ? errorFieldStyle : {})
@@ -478,6 +479,9 @@ const themeStyles = themeContext.useThemeStyles({
     borderBottomColor: DynamicColor('FieldBorderBottomColor', 'border.cell'),
     borderBottomStyle: 'solid',
   },
+  fieldVertical: {
+    gap: DynamicSize('FieldVerticalGap', 20),
+  },
   requiredMark: {
     fontSize: DynamicSize('FieldRequiredMark', 28),
     alignSelf: 'flex-start',

+ 14 - 1
src/pages.json

@@ -123,8 +123,21 @@
         "navigationBarTitleText": "信息列表",
         "enablePullDownRefresh": false
       }
+    },
+    {
+      "path": "pages/editor/editor",
+      "style": {
+        "navigationBarTitleText": "编辑文章",
+        "enablePullDownRefresh": false
+      }
+    },
+    {
+      "path": "pages/editor/preview",
+      "style": {
+        "navigationBarTitleText": "预览文章",
+        "enablePullDownRefresh": false
+      }
     }
-
   ],
   "globalStyle": {
     "navigationBarTextStyle": "white",

+ 1 - 1
src/pages/dig/composeable/TaskEntryForm.ts

@@ -8,7 +8,7 @@ export function useTaskEntryForm() {
   });
   
   function goForm(subType: string, subId: number, subKey = 'type', type = 'list') {
-    navTo('../forms/' + type, {
+    navTo('/pages/dig/forms/' + type, {
       villageId: querys.value.villageId,  
       villageVolunteerId: querys.value.villageVolunteerId,  
       subType,

+ 1 - 1
src/pages/dig/details.vue

@@ -149,7 +149,7 @@ const nextPageData = computed(() => ({
 }));
 
 function goForm(subType: string, subId: number) {
-  navTo('../forms/list', {
+  navTo('/pages/dig/forms/list', {
     villageId: querys.value.id,  
     villageVolunteerId: querys.value.villageVolunteerId,  
     subType,

+ 112 - 45
src/pages/dig/forms/forms.ts

@@ -3,6 +3,8 @@ import type { FormDefine, FormDefineItem, IFormItemCallbackAdditionalProps } fro
 import type { FormGroupProps } from "@/components/dynamic/DynamicFormCate.vue";
 import type { CheckBoxListProps } from "@/components/dynamic/wrappers/CheckBoxList.vue";
 import type { IDynamicFormItemSelectIdFormItemProps, IDynamicFormItemSelectIdOption } from "@/components/dynamic/wrappers/PickerIdField";
+import type { FieldProps } from "@/components/form/Field.vue";
+import type { PickerFieldProps } from "@/components/form/PickerField.vue";
 import type { NewDataModel } from "@imengyu/js-request-transform";
 
 type SingleForm = [NewDataModel, FormDefine]
@@ -48,6 +50,7 @@ const villageInfoBuildingForm : SingleForm = [VillageBulidingInfo, {
           }))
         ,
       } as IDynamicFormItemSelectIdFormItemProps,
+      itemParams: { showRightArrow: true } as FieldProps,
       rules: [{
         required: true,
         message: '请选择产权归属',
@@ -79,6 +82,7 @@ const villageInfoBuildingForm : SingleForm = [VillageBulidingInfo, {
           }))
         ,
       } as IDynamicFormItemSelectIdFormItemProps,
+      itemParams: { showRightArrow: true } as FieldProps,
       rules: [{
         required: true,
         message: '请选择建筑类型',
@@ -110,6 +114,7 @@ const villageInfoBuildingForm : SingleForm = [VillageBulidingInfo, {
           }))
         ,
       } as IDynamicFormItemSelectIdFormItemProps,
+      itemParams: { showRightArrow: true } as FieldProps,
       rules: [{
         required: true,
         message: '请选择建筑类型',
@@ -199,7 +204,7 @@ const villageInfoBuildingForm : SingleForm = [VillageBulidingInfo, {
     {
       label: '承重结构(多选)', 
       name: 'bearingType', 
-      type: 'checkbox-list', 
+      type: 'check-box-list', 
       params: {
         multiple: true,
         loadData: async () => 
@@ -275,7 +280,7 @@ const villageInfoBuildingForm : SingleForm = [VillageBulidingInfo, {
     {
       label: '屋面形式(多选)', 
       name: 'roofForm', 
-      type: 'checkbox-list', 
+      type: 'check-box-list', 
       params: {
         multiple: true,
         loadData: async () => 
@@ -300,7 +305,7 @@ const villageInfoBuildingForm : SingleForm = [VillageBulidingInfo, {
     {
       label: '围护墙体(多选)', 
       name: 'wallType', 
-      type: 'checkbox-list', 
+      type: 'check-box-list', 
       params: {
         multiple: true,
         loadData: async () => 
@@ -325,7 +330,7 @@ const villageInfoBuildingForm : SingleForm = [VillageBulidingInfo, {
     {
       label: '地面做法(多选)', 
       name: 'floorType', 
-      type: 'checkbox-list', 
+      type: 'check-box-list', 
       params: {
         multiple: true,
         loadData: async () => 
@@ -366,7 +371,7 @@ const villageInfoBuildingForm : SingleForm = [VillageBulidingInfo, {
     {
       label: '现状用途(多选)', 
       name: 'purpose', 
-      type: 'checkbox-list', 
+      type: 'check-box-list', 
       params: {
         multiple: true,
         loadData: async () => 
@@ -452,6 +457,7 @@ const villageInfoFolkCultureForm : SingleForm = [VillageBulidingInfo, {
             }))
         },
       } as IFormItemCallbackAdditionalProps<IDynamicFormItemSelectIdFormItemProps>,
+      itemParams: { showRightArrow: true } as FieldProps,
       rules: [],
     },
     {
@@ -538,6 +544,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           params: {
             placeholder: '请选择村落地址',
           },
+          itemParams: { showRightArrow: true } as FieldProps,
           rules:  [{
             required: true,
             message: '请选择村落地址',
@@ -569,6 +576,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择类型',
@@ -581,9 +589,10 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         { 
           label: '经纬度', 
           name: 'lonlat', 
-          type: 'lonlat-picker', 
+          type: 'select-lonlat', 
           defaultValue: '',
           params: {},
+          itemParams: { showRightArrow: true } as FieldProps,
           rules:  [{
             required: true,
             message: '请输入村落经纬度',
@@ -608,7 +617,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         { 
           label: '地形地貌特征(多选)', 
           name: 'landforms',
-          type: 'checkbox-list', 
+          type: 'check-box-list', 
           params: {
             multiple: true,
             loadData: async () => 
@@ -670,6 +679,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择类型',
@@ -693,6 +703,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择非遗最高级别',
@@ -726,6 +737,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择类型',
@@ -744,6 +756,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择类型',
@@ -752,7 +765,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         { 
           label: '列入少数民族特色村寨试点示范', 
           name: 'isFeaturedVillage', 
-          type: 'boolint-checkbox', 
+          type: 'check-box-int', 
           defaultValue: '',
           params: {},
           rules: [{
@@ -942,6 +955,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
                     }))
                   ,
                 } as IDynamicFormItemSelectIdFormItemProps,
+                itemParams: { showRightArrow: true } as FieldProps,
                 rules: [],
               })),
               { 
@@ -1239,6 +1253,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择年代',
@@ -1257,6 +1272,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择	要素类型',
@@ -1426,7 +1442,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         {
           label: '分布图', 
           name: 'distribution', 
-          type: 'image-uploader', 
+          type: 'uploader-image', 
           defaultValue: '',
           params: {
           },
@@ -1493,6 +1509,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择年代',
@@ -1533,6 +1550,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择保护级别',
@@ -1551,6 +1569,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择文物类型',
@@ -1559,9 +1578,10 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         {
           label: '经纬度', 
           name: 'lonlat', 
-          type: 'lonlat-picker', 
+          type: 'select-lonlat', 
           defaultValue: '',
           params: {},
+          itemParams: { showRightArrow: true } as FieldProps,
           rules:  [{
             required: true,
             message: '请输入经纬度',
@@ -1642,7 +1662,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         {
           label: '图片', 
           name: 'images', 
-          type: 'image-uploader', 
+          type: 'uploader-image', 
           defaultValue: '',
           params: {
           },
@@ -1734,6 +1754,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择级别',
@@ -1752,6 +1773,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择类型',
@@ -1760,7 +1782,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         {
           label: '是否确定传承人', 
           name: 'isInheritor', 
-          type: 'boolint-checkbox', 
+          type: 'check-box-int', 
           defaultValue: 0,
           params: {},
           rules:  [{
@@ -1794,6 +1816,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择项目续存情况',
@@ -1812,6 +1835,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择与村落依存程度',
@@ -1830,6 +1854,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择活动规模',
@@ -1848,6 +1873,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择传承时间',
@@ -1856,7 +1882,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         {
           label: '具体传承时间', 
           name: 'otherInheritanceTime',
-          type: 'datetime-picker', 
+          type: 'picker-datetime', 
           hidden: { callback(model, rawModel) {
             return !(rawModel.inheritanceTime === 150);
           } },
@@ -1871,7 +1897,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         {
           label: '加入时间', 
           name: 'joinAt',
-          type: 'datetime-picker', 
+          type: 'picker-datetime', 
           params: {
             type: 'datetime',
           },
@@ -1949,9 +1975,11 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         {
           label: '有无公交车', 
           name: 'isBus', 
-          type: 'boolint-checkbox', 
+          type: 'check-box-int', 
           defaultValue: 0,
-          params: {},
+          params: {
+            text: '有',
+          },
           rules:  [{
             required: true,
             message: '请选择有无公交车',
@@ -1963,7 +1991,9 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           type: 'text', 
           hidden: { callback: (_, rawModel) => !(rawModel.isBus === 1) },
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请输入公交车介绍',
+          },
           rules:  [] 
         },
         {
@@ -1971,7 +2001,9 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           name: 'tollStation', 
           type: 'text', 
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请输入最近高速收费站名称',
+          },
           rules:  [] 
         },
         {
@@ -1979,7 +2011,9 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           name: 'trainStation', 
           type: 'text', 
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请输入距离最近火车站',
+          },
           rules:  [] 
         },
         {
@@ -1987,32 +2021,40 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           name: 'otherBus', 
           type: 'text', 
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请输入其他交通方式',
+          },
           rules:  [] 
         },
         {
           label: '景区全景图', 
           name: 'panorama', 
-          type: 'image-uploader', 
+          type: 'uploader-image', 
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请上传景区全景图',
+          },
           rules:  [] 
         },
         {
           label: '其他图', 
           name: 'otherImage', 
-          type: 'image-uploader', 
+          type: 'uploader-image', 
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请上传其他图',
+          },
           rules:  [] 
         },
         //解说牌
         {
           label: '有无解说牌', 
           name: 'introBoard', 
-          type: 'boolint-checkbox', 
+          type: 'check-box-int', 
           defaultValue: 0,
-          params: {},
+          params: {
+            text: '有',
+          },
           rules:  [{
             required: true,
             message: '请选择有无解说牌',
@@ -2023,15 +2065,19 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           name: 'otherIntroBoard', 
           type: 'text', 
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请输入其他解说牌',
+          },
           rules:  [] 
         },
         {
           label: '有无解指示牌', 
           name: 'indicateBoard', 
-          type: 'boolint-checkbox', 
+          type: 'check-box-int', 
           defaultValue: 0,
-          params: {},
+          params: {
+            text: '有',
+          },
           rules:  [{
             required: true,
             message: '请选择有无指示牌',
@@ -2042,15 +2088,19 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           name: 'otherIndicateBoard', 
           type: 'text', 
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请输入其他指示牌',
+          },
           rules:  [] 
         },
         {
           label: '有无安全告示牌', 
           name: 'safeBoard', 
-          type: 'boolint-checkbox', 
+          type: 'check-box-int', 
           defaultValue: 0,
-          params: {},
+          params: {
+            text: '有',
+          },
           rules:  [{
             required: true,
             message: '请选择有无安全告示牌',
@@ -2061,15 +2111,19 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           name: 'otherSafeBoard', 
           type: 'text', 
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请输入其他安全告示牌',
+          },
           rules:  [] 
         },
         {
           label: '有无游客服务中心', 
           name: 'visitorCenter', 
-          type: 'boolint-checkbox', 
+          type: 'check-box-int', 
           defaultValue: 0,
-          params: {},
+          params: {
+            text: '有',
+          },
           rules:  [{
             required: true,
             message: '请选择有有无游客服务中心',
@@ -2080,7 +2134,9 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           name: 'visitorCenterArea', 
           type: 'text', 
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请输入游客服务中心面积',
+          },
           rules:  [] 
         },
         {
@@ -2096,6 +2152,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
               }))
             ,
           } as IDynamicFormItemSelectIdFormItemProps,
+          itemParams: { showRightArrow: true } as FieldProps,
           rules: [{
             required: true,
             message: '请选择商业设施',
@@ -2106,7 +2163,9 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           name: 'otherBusiness', 
           type: 'text', 
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请输入其他商业设施',
+          },
           rules:  [] 
         },
         //医疗点
@@ -2116,11 +2175,14 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           type: 'select', 
           defaultValue: 0,
           params: {
-            localdata: [
+            columns: [[
               { value: 0, text: '无' },
               { value: 1, text: '有' },
               { value: 2, text: '其他' }
-            ]
+            ]]
+          } as PickerFieldProps,
+          itemParams: {
+            showRightArrow: true,
           },
           rules:  [{
             required: true,
@@ -2143,11 +2205,14 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           type: 'select', 
           defaultValue: 0,
           params: {
-            localdata: [
+            columns: [[
               { value: 0, text: '无' },
               { value: 1, text: '有' },
               { value: 2, text: '其他' }
-            ]
+            ]]
+          } as PickerFieldProps,
+          itemParams: {
+            showRightArrow: true,
           },
           rules:  [{
             required: true,
@@ -2160,7 +2225,9 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
           type: 'text', 
           hidden: { callback: (_, rawModel) => !(rawModel.tourBus === 2) },
           defaultValue: '',
-          params: {},
+          params: {
+            placeholder: '请输入其他游览车',
+          },
           rules:  [] 
         },
         //
@@ -2292,7 +2359,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         {
           label: '活动开始时间', 
           name: 'startTime',
-          type: 'datetime-picker', 
+          type: 'picker-datetime', 
           defaultValue: '',
           params: {
             type: 'datetime',
@@ -2305,7 +2372,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         {
           label: '活动结束时间', 
           name: 'endTime',
-          type: 'datetime-picker', 
+          type: 'picker-datetime', 
           defaultValue: '',
           params: {
             type: 'datetime',
@@ -2400,7 +2467,7 @@ const villageInfoForm : Record<string, Record<number, SingleForm>> = {
         {
           label: '图片视频', 
           name: 'images', 
-          type: 'image-uploader', 
+          type: 'uploader-image', 
           defaultValue: '',
           params: {
           },

+ 26 - 29
src/pages/dig/forms/list.vue

@@ -1,41 +1,37 @@
 <template>
-  <view class="article_list">
-    <view class="search with-button">
-      <uni-search-bar 
+  <FlexCol :padding="30">
+    <FlexRow justify="space-between">
+      <SearchBar
         v-model="searchText"
-        radius="100"
-        bgColor="#fff"
         placeholder="搜一搜" 
-        clearButton="auto"
-        cancelButton="none"
+        :innerStyle="{ width: '460rpx' }"
         @confirm="search"
       />
-      <u-button type="primary" @click="newData">+ 新增</u-button>
-    </view>
-    <view class="complex-list-horizontal-1">
-      <view 
+      <Button type="primary" @click="newData">+ 新增</Button>
+    </FlexRow>
+    <FlexCol>
+      <FlexRow 
         class="item" 
         hover-class="pressed"
         v-for="item in listLoader.list.value"
         :key="item.id" 
         @click="goDetail(item.id)"
       >
-        <ImageWrapper :src="item.image" width="170rpx" height="190rpx" />
-        <view class="info">
-          <view class="name ellipsis-2">{{ item.title }}</view>
-          <view class="desc">{{ item.date }}</view>
-        </view>
-      </view>
-    </view>
+        <Image :src="item.image" width="170rpx" height="190rpx" />
+        <FlexCol>
+          <Text :size="36">{{ item.title }}</Text>
+          <Text :size="23">{{ item.date }}</Text>
+        </FlexCol>
+      </FlexRow>
+    </FlexCol>
     <SimplePageListLoader :loader="listLoader" :noEmpty="true">
       <template #empty>
-        <u-empty mode="list" text="暂无数据,点击按钮新增数据">
-          <u-gap height="20"></u-gap>
-          <u-button type="primary" @click="newData">+ 新增数据</u-button>
-        </u-empty>
+        <Empty image="search" text="暂无数据,点击按钮新增数据">
+          <Button type="primary" @click="newData">+ 新增数据</Button>
+        </Empty>
       </template>
     </SimplePageListLoader>
-  </view>
+  </FlexCol>
 </template>
 
 <script setup lang="ts">
@@ -47,6 +43,13 @@ import { DataDateUtils } from '@imengyu/js-request-transform';
 import { navTo } from '@imengyu/imengyu-utils/dist/uniapp/PageAction';
 import { useLoadQuerys } from '@/common/composeabe/LoadQuerys';
 import VillageInfoApi from '@/api/inhert/VillageInfoApi';
+import Image from '@/components/basic/Image.vue';
+import Empty from '@/components/feedback/Empty.vue';
+import Button from '@/components/basic/Button.vue';
+import SearchBar from '@/components/form/SearchBar.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import Text from '@/components/basic/Text.vue';
 
 const searchText = ref('');
 const listLoader = useSimplePageListLoader<{
@@ -127,9 +130,3 @@ defineExpose({
   }
 })
 </script>
-
-<style lang="scss">
-.article_list {
-  padding: 20rpx;
-}
-</style>

+ 4 - 2
src/pages/dig/index.vue

@@ -37,9 +37,10 @@
               <Width :size="20" />
               <FlexCol>
                 <H3>{{ item.villageName }}</H3>
+                <Height :size="10" />
                 <FlexRow align="center" :flex="1" :gap="10">
-                  <Button type="primary" icon="work-filling" @click="goManagePage(item)">管理</Button>
-                  <Button type="default" icon="edit-filling" @click="goSubmitDigPage(item)">采编</Button>
+                  <Button type="primary" size="large" icon="work-filling" @click="goManagePage(item)">管理</Button>
+                  <Button type="default" size="large" icon="edit-filling" @click="goSubmitDigPage(item)">采编</Button>
                 </FlexRow>
               </FlexCol>
             </FlexRow>
@@ -81,6 +82,7 @@ import { useCollectStore } from '@/store/collect';
 import { useSimpleDataLoader } from '@/common/composeabe/SimpleDataLoader';
 import Text from '@/components/basic/Text.vue';
 import Icon from '@/components/basic/Icon.vue';
+import Height from '@/components/layout/space/Height.vue';
 
 const authStore = useAuthStore();
 const collectStore = useCollectStore();

+ 108 - 0
src/pages/editor/editor.vue

@@ -0,0 +1,108 @@
+<template>
+  <view class="d-flex flex-column h-100vh">
+    <sp-editor
+      :toolbar-config="{
+        excludeKeys: ['direction', 'date', 'lineHeight', 'letterSpacing', 'listCheck'],
+        iconSize: '18px'
+      }"
+      @init="initEditor"
+      @input="inputOver"
+      @upinImage="upinImage"
+      @overMax="overMax"
+    ></sp-editor>
+    
+    <view class="d-flex flex-row align-center gap-s p-3">
+      <Button type="danger" @click="cancel">取消</Button>
+      <Button type="primary" @click="save">保存</Button>
+    </view>
+    <XBarSpace />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { showError } from '@/common/composeabe/ErrorDisplay';
+import { confirm } from '@imengyu/imengyu-utils/dist/uniapp/DialogAction';
+import { back, backAndCallOnPageBack } from '@imengyu/imengyu-utils/dist/uniapp/PageAction';
+import spEditor from '@/uni_modules/sp-editor/components/sp-editor/sp-editor.vue';
+import XBarSpace from '@/components/layout/space/XBarSpace.vue';
+import Button from '@/components/basic/Button.vue';
+
+function cancel() {
+  confirm({
+    title: '提示',
+    content: '是否放弃编辑?',
+  }).then((res) => {
+    if (res)
+      back();
+  })
+}
+function save() {
+  console.log('save', currentContent);
+  
+  uni.setStorage({
+    key: 'editorContent',
+    data: currentContent,
+    success: () => backAndCallOnPageBack('editor', {}),
+    fail: (e) => showError(e),
+  })
+}
+
+let currentContent = '';
+
+/**
+* 获取输入内容
+*/
+function inputOver(e: { html: string; text: string; }) {
+  // 可以在此处获取到编辑器已编辑的内容
+  currentContent = e.html;
+}
+/**
+ * 超出最大内容限制
+ * @param {Object} e {html,text} 内容的html文本,和text文本
+ */
+function overMax(e: { html: string; text: string; }) {
+  // 若设置了最大字数限制,可在此处触发超出限制的回调
+  console.log('==== overMax :', e)
+}
+function initEditor(editor: any) {
+  uni.getStorage({
+    key: 'editorContent',
+    success: (success) => {
+      editor.setContents({
+        html: success.data
+      })
+    },
+  })
+}
+/**
+ * 直接运行示例工程插入图片无法正常显示的看这里
+ * 因为插件默认采用云端存储图片的方式
+ * 以$emit('upinImage', tempFiles, this.editorCtx)的方式回调
+ * @param {Object} tempFiles
+ * @param {Object} editorCtx
+ */
+function upinImage(tempFiles: any, editorCtx: any) {
+  /**
+   * 本地临时插入图片预览
+   * 注意:这里仅是示例本地图片预览,因为需要将图片先上传到云端,再将图片插入到编辑器中
+   * 正式开发时,还请将此处注释,并解开下面 使用 uniCloud.uploadFile 上传图片的示例方法 的注释
+   * @tutorial https://uniapp.dcloud.net.cn/api/media/editor-context.html#editorcontext-insertimage
+   */
+  // #ifdef MP-WEIXIN
+  // 注意微信小程序的图片路径是在tempFilePath字段中
+  editorCtx.insertImage({
+    src: tempFiles[0].tempFilePath,
+    width: '80%', // 默认不建议铺满宽度100%,预留一点空隙以便用户编辑
+    success: function () {}
+  })
+  // #endif
+
+  // #ifndef MP-WEIXIN
+  editorCtx.insertImage({
+    src: tempFiles[0].path,
+    width: '80%', // 默认不建议铺满宽度100%,预留一点空隙以便用户编辑
+    success: function () {}
+  })
+  // #endif
+}
+</script>

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

@@ -0,0 +1,26 @@
+<template>
+  <view>
+    <Empty v-if="!content" image="search" text="空内容,请先编写内容后再预览" />
+    <view v-else class="p-3">
+      <Parse :content="content" />
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import Parse from '@/components/display/parse/Parse.vue';
+import Empty from '@/components/feedback/Empty.vue';
+import { onLoad } from '@dcloudio/uni-app';
+import { ref } from 'vue';
+
+const content = ref();
+
+onLoad(() => {
+  uni.getStorage({
+    key: 'editorContent',
+    success: (success) => {
+      content.value = success.data;
+    },
+  })
+})
+</script>

+ 131 - 0
src/uni_modules/sp-editor/changelog.md

@@ -0,0 +1,131 @@
+## 1.5.0(2024-11-01)
+1. 更新弹窗中使用的示例
+## 1.4.9(2024-08-30)
+1. 修复在app端和小程序端插入超链接可能会回显出部分特殊标识的bug
+## 1.4.8(2024-08-16)
+1. 有群友反馈,通过wangEditor添加的含有图片的富文本的宽高是在style内联样式中的,但是uni-editor不能解析img标签的内联宽高,因此我封装一个工具方法convertImgStylesToAttributes,处理一下富文本字符串即可,详见示例一
+## 1.4.7(2024-08-12)
+1. 新增标题、字体、字体大小、字间距段前后距等工具子级悬浮工具栏
+2. 对于字体样式:可在组件的data中的fabTools.fontFamily中自行引入添加你本地的自定义字体(app中对于部分css自带的字体例如微软雅黑等不支持,因为app中不自带这些字体,你可能需要自行引入字体文件)
+## 1.4.6(2024-08-08)
+1. 修复字体工具栏无法隐藏的bug
+## 1.4.5(2024-07-21)
+1. 解决video图标冲突问题
+## 1.4.4(2024-07-20)
+1. 新增视频插入功能
+2. 更新示例一(关于视频插入参考请见示例一)
+## 1.4.3(2024-07-16)
+1. 更新示例工程
+## 1.4.2(2024-07-16)
+1. 更新示例工程
+## 1.4.1(2024-06-14)
+1. 更新示例工程
+## 1.4.0(2024-05-31)
+1. 更新示例工程
+## 1.3.9(2024-05-31)
+1. 修复调色板无法正常选色的问题
+## 1.3.8(2024-05-13)
+1. 更新示例三(微信小程序上使用setContents造成聚焦滚动的处理)
+## 1.3.7(2024-05-10)
+1. 修复添加超链接后,不触发input更新当前最新内容的bug
+## 1.3.6(2024-05-09)
+1. 文档迁移
+## 1.3.5(2024-05-09)
+1. 所有事件携带编辑器id参数,以便循环时能区分处理
+2. 更新示例工程
+## 1.3.4(2024-05-08)
+1. 更新示例工程
+2. 新增editorId参数
+## 1.3.3(2024-03-22)
+1. 修复微信小程序长按无法粘贴的问题
+## 1.3.2(2024-03-14)
+1. 更新了toolbar样式与配置,见文档
+2. 更新示例工程,媒体查询响应式写法
+3. 优化了只读模式效果,开启只读模式后,文章内容的超链接可正常点击并跳转
+## 1.3.1(2024-03-14)
+1. 优化了只读功能,开启只读后自动隐藏工具栏
+2. 更新示例工程
+## 1.3.0(2024-03-07)
+1. 新增addLink的emit事件
+## 1.2.9(2024-02-23)
+1. 更新文档
+## 1.2.8(2024-02-23)
+1. 新增了添加超链接的工具,toolbar中link字段,默认开启
+2. 优化了部分逻辑
+3. 更新文档、更新示例工程
+## 1.2.7(2024-02-23)
+1. 更新文档,更新示例工程
+2. 添加toolbar中图标字体大小可配置项
+## 1.2.6(2024-02-22)
+1. 添加导出工具按钮,可将当前已编辑的html导出至页面解析
+2. 超链接工具按钮正在尝试开发中(貌似目前官方不支持)
+## 1.2.5(2024-02-19)
+1. 更新示例工程(吸顶写法)
+2. 完善调色板功能
+## 1.2.4(2024-02-18)
+1. 修复工具栏颜色按钮底色动态切换问题
+## 1.2.3(2024-02-18)
+1. 更新示例工程
+## 1.2.2(2024-02-18)
+1. 删除log调试打印
+## 1.2.1(2024-02-18)
+1. 修复了颜色图标不会动态切换的问题
+## 1.2.0(2024-02-18)
+1. 修复选择颜色时会将所选文字删除的bug
+## 1.1.9(2024-02-04)
+1. 更新示例工程
+## 1.1.8(2024-02-04)
+1. 文档修改
+## 1.1.7(2024-02-04)
+1. 新增toolbar配置项,可自由配置工具栏工具列表
+2. 移除组件内原templates属性,默认初始化编辑器内容请看文档使用方式示例
+3. 更新文档
+## 1.1.6(2024-01-31)
+1. 更好的兼容vue2了,修复在vue2下高度可能超出的问题
+2. 示例工程兼容vue2
+## 1.1.5(2024-01-30)
+1. 修复工具栏字体按钮无效的问题
+## 1.1.4(2024-01-30)
+1. 解决默认初始化内容时前缀空格或缩进无效的问题
+2. 解决点击工具栏高亮状态后输入内容时便失去高亮的bug
+3. 更新示例工程
+## 1.1.3(2024-01-23)
+1. 重写高度动态计算逻辑,现在对不同屏幕尺寸的适应性更强了
+## 1.1.2(2024-01-17)
+1. 修复分割线会生成多条的问题
+## 1.1.1(2024-01-15)
+1. 更新文档
+## 1.1.0(2024-01-15)
+1. insertText方法在插入内容的时候会移动光标聚焦,导致焦点回滚到视口处
+2. 更新示例工程
+## 1.0.9(2024-01-04)
+1. 更新文档
+## 1.0.8(2024-01-04)
+1. 修复h5端官方cdn请求失败的问题,详见问答贴:https://ask.dcloud.net.cn/article/40900
+## 1.0.7(2024-01-03)
+1.  移除v-bind="$attrs",该写法在微信小程序不支持
+## 1.0.6(2023-12-29)
+1. 更新文档
+## 1.0.5(2023-12-29)
+1. 更新了init方法,可以使用返回的editor实例尽情自定义
+2. 组件在<editor>上添加v-bind="$attrs"属性穿透,可以使用原editor参数,官方文档:https://uniapp.dcloud.net.cn/component/editor.html
+## 1.0.4(2023-12-29)
+1. 优化了切换文字和背景颜色是,可能会导致切换后不生效的问题
+2. 修复在部分设备上的微信小程序中可能会存在颜色版无法正常滑动的问题
+3. 更友好的交互体验:添加图标悬停字样描述、添加格式化文本弹窗确认
+4. 有插入视频的需求,暂时可能无法实现,官方给予的回复是:目前各端的eidtor组件都不能直接插入视频,编辑时可以采用视频封面占位,并在图片中保存视频信息,在预览时再还原为视频。
+## 1.0.3(2023-10-13)
+	1. 更新readme文档
+	2. 更新调整组件示例项目,添加插件代码中部分注释
+## 1.0.2(2023-10-13)
+	1. 更新uni_modules规范,可一键导入组件
+	2. 更新组件示例项目(包括使用uniCloud.uploadFile多选上传图片示例方法)
+## 1.0.1(2023-10-12)
+	1. 修复小程序中自动聚焦滚动到富文本组件区域的bug
+	2. 略微调整了富文本上方toolbar工具栏中按钮的大小尺寸
+## 1.0.0(2023-9-19)
+	1. 新增字体与背景颜色板
+	2. 可自定义预设内容模板
+	3. 解决官方样例在小程序和app部分报错不兼容的问题
+	4. 可配合云存储上传富文本中插入的图片 本质上是基于官方内置富文本editor组件改版封装,所以官方有的功能都有,官方能兼容的也都兼容
+

+ 825 - 0
src/uni_modules/sp-editor/components/sp-editor/color-picker.vue

@@ -0,0 +1,825 @@
+<template>
+  <view v-if="show" class="t-wrapper" @touchmove.stop.prevent="moveHandle">
+    <view class="t-mask" :class="{ active: active }" @click.stop="close"></view>
+    <view class="t-box" :class="{ active: active }">
+      <view class="t-header">
+        <view class="t-header-button" @click="close">取消</view>
+        <view class="t-header-button" @click="confirm">确认</view>
+      </view>
+      <view class="t-color__box" :style="{ background: 'rgb(' + bgcolor.r + ',' + bgcolor.g + ',' + bgcolor.b + ')' }">
+        <view
+          class="t-background boxs"
+          @touchstart="touchstart($event, 0)"
+          @touchmove="touchmove($event, 0)"
+          @touchend="touchend($event, 0)"
+        >
+          <view class="t-color-mask"></view>
+          <view class="t-pointer" :style="{ top: site[0].top - 8 + 'px', left: site[0].left - 8 + 'px' }"></view>
+        </view>
+      </view>
+      <view class="t-control__box">
+        <view class="t-control__color">
+          <view
+            class="t-control__color-content"
+            :style="{ background: 'rgba(' + rgba.r + ',' + rgba.g + ',' + rgba.b + ',' + rgba.a + ')' }"
+          ></view>
+        </view>
+        <view class="t-control-box__item">
+          <view
+            class="t-controller boxs"
+            @touchstart="touchstart($event, 1)"
+            @touchmove="touchmove($event, 1)"
+            @touchend="touchend($event, 1)"
+          >
+            <view class="t-hue">
+              <view class="t-circle" :style="{ left: site[1].left - 12 + 'px' }"></view>
+            </view>
+          </view>
+          <view
+            class="t-controller boxs"
+            @touchstart="touchstart($event, 2)"
+            @touchmove="touchmove($event, 2)"
+            @touchend="touchend($event, 2)"
+          >
+            <view class="t-transparency">
+              <view class="t-circle" :style="{ left: site[2].left - 12 + 'px' }"></view>
+            </view>
+          </view>
+        </view>
+      </view>
+      <view class="t-result__box">
+        <view v-if="mode" class="t-result__item">
+          <view class="t-result__box-input">{{ hex }}</view>
+          <view class="t-result__box-text">HEX</view>
+        </view>
+        <template v-else>
+          <view class="t-result__item">
+            <view class="t-result__box-input">{{ rgba.r }}</view>
+            <view class="t-result__box-text">R</view>
+          </view>
+          <view class="t-result__item">
+            <view class="t-result__box-input">{{ rgba.g }}</view>
+            <view class="t-result__box-text">G</view>
+          </view>
+          <view class="t-result__item">
+            <view class="t-result__box-input">{{ rgba.b }}</view>
+            <view class="t-result__box-text">B</view>
+          </view>
+          <view class="t-result__item">
+            <view class="t-result__box-input">{{ rgba.a }}</view>
+            <view class="t-result__box-text">A</view>
+          </view>
+        </template>
+
+        <view class="t-result__item t-select" @click="select">
+          <view class="t-result__box-input">
+            <view>切换</view>
+            <view>模式</view>
+          </view>
+        </view>
+      </view>
+      <view class="t-alternative">
+        <view class="t-alternative__item" v-for="(item, index) in colorList" :key="index">
+          <view
+            class="t-alternative__item-content"
+            :style="{ background: 'rgba(' + item.r + ',' + item.g + ',' + item.b + ',' + item.a + ')' }"
+            @click="selectColor(item)"
+          ></view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    color: {
+      type: Object,
+      default: () => {
+        return {
+          r: 0,
+          g: 0,
+          b: 0,
+          a: 0
+        }
+      }
+    },
+    spareColor: {
+      type: Array,
+      default() {
+        return []
+      }
+    }
+  },
+  data() {
+    return {
+      show: false,
+      active: false,
+      // rgba 颜色
+      rgba: {
+        r: 0,
+        g: 0,
+        b: 0,
+        a: 1
+      },
+      // hsb 颜色
+      hsb: {
+        h: 0,
+        s: 0,
+        b: 0
+      },
+      site: [
+        {
+          top: 0,
+          left: 0
+        },
+        {
+          left: 0
+        },
+        {
+          left: 0
+        }
+      ],
+      index: 0,
+      bgcolor: {
+        r: 255,
+        g: 0,
+        b: 0,
+        a: 1
+      },
+      hex: '#000000',
+      mode: true,
+      colorList: [
+        {
+          r: 244,
+          g: 67,
+          b: 54,
+          a: 1
+        },
+        {
+          r: 233,
+          g: 30,
+          b: 99,
+          a: 1
+        },
+        {
+          r: 156,
+          g: 39,
+          b: 176,
+          a: 1
+        },
+        {
+          r: 103,
+          g: 58,
+          b: 183,
+          a: 1
+        },
+        {
+          r: 63,
+          g: 81,
+          b: 181,
+          a: 1
+        },
+        {
+          r: 33,
+          g: 150,
+          b: 243,
+          a: 1
+        },
+        {
+          r: 3,
+          g: 169,
+          b: 244,
+          a: 1
+        },
+        {
+          r: 0,
+          g: 188,
+          b: 212,
+          a: 1
+        },
+        {
+          r: 0,
+          g: 150,
+          b: 136,
+          a: 1
+        },
+        {
+          r: 76,
+          g: 175,
+          b: 80,
+          a: 1
+        },
+        {
+          r: 139,
+          g: 195,
+          b: 74,
+          a: 1
+        },
+        {
+          r: 205,
+          g: 220,
+          b: 57,
+          a: 1
+        },
+        {
+          r: 255,
+          g: 235,
+          b: 59,
+          a: 1
+        },
+        {
+          r: 255,
+          g: 193,
+          b: 7,
+          a: 1
+        },
+        {
+          r: 255,
+          g: 152,
+          b: 0,
+          a: 1
+        },
+        {
+          r: 255,
+          g: 87,
+          b: 34,
+          a: 1
+        },
+        {
+          r: 121,
+          g: 85,
+          b: 72,
+          a: 1
+        },
+        {
+          r: 158,
+          g: 158,
+          b: 158,
+          a: 1
+        },
+        {
+          r: 0,
+          g: 0,
+          b: 0,
+          a: 0.5
+        },
+        {
+          r: 0,
+          g: 0,
+          b: 0,
+          a: 0
+        }
+      ]
+    }
+  },
+  created() {
+    this.ready()
+  },
+  methods: {
+    ready() {
+      this.rgba = this.color
+      if (this.spareColor.length !== 0) {
+        this.colorList = this.spareColor
+      }
+    },
+    /**
+     * 初始化
+     */
+    init() {
+      // hsb 颜色
+      this.hsb = this.rgbToHex(this.rgba)
+      // this.setColor();
+      this.setValue(this.rgba)
+    },
+    moveHandle() {},
+    open() {
+      this.show = true
+      this.$nextTick(() => {
+        this.init()
+        setTimeout(() => {
+          this.active = true
+          setTimeout(() => {
+            this.getSelectorQuery()
+          }, 350)
+        }, 50)
+      })
+    },
+    close() {
+      this.active = false
+      this.$nextTick(() => {
+        setTimeout(() => {
+          this.show = false
+        }, 500)
+      })
+    },
+    confirm() {
+      this.close()
+      this.$emit('confirm', {
+        rgba: this.rgba,
+        hex: this.hex
+      })
+    },
+    // 选择模式
+    select() {
+      this.mode = !this.mode
+    },
+    // 常用颜色选择
+    selectColor(item) {
+      this.setColorBySelect(item)
+    },
+    touchstart(e, index) {
+      const { pageX, pageY, clientX, clientY } = e.touches[0]
+      // 部分机型可能没有pageX或clientX,因此此处需要做兼容
+      this.moveX = clientX || pageX
+      this.moveY = clientY || pageY
+      this.setPosition(this.moveX, this.moveY, index)
+    },
+    touchmove(e, index) {
+      const { pageX, pageY, clientX, clientY } = e.touches[0]
+      this.moveX = clientX || pageX
+      this.moveY = clientY || pageY
+      this.setPosition(this.moveX, this.moveY, index)
+    },
+    touchend(e, index) {},
+    /**
+     * 设置位置
+     */
+    setPosition(x, y, index) {
+      this.index = index
+      const { top, left, width, height } = this.position[index]
+      // 设置最大最小值
+
+      this.site[index].left = Math.max(0, Math.min(parseInt(x - left), width))
+      if (index === 0) {
+        this.site[index].top = Math.max(0, Math.min(parseInt(y - top), height))
+        // 设置颜色
+        this.hsb.s = parseInt((100 * this.site[index].left) / width)
+        this.hsb.b = parseInt(100 - (100 * this.site[index].top) / height)
+        this.setColor()
+        this.setValue(this.rgba)
+      } else {
+        this.setControl(index, this.site[index].left)
+      }
+    },
+    /**
+     * 设置 rgb 颜色
+     */
+    setColor() {
+      const rgb = this.HSBToRGB(this.hsb)
+      this.rgba.r = rgb.r
+      this.rgba.g = rgb.g
+      this.rgba.b = rgb.b
+    },
+    /**
+     * 设置二进制颜色
+     * @param {Object} rgb
+     */
+    setValue(rgb) {
+      this.hex = '#' + this.rgbToHex(rgb)
+    },
+    setControl(index, x) {
+      const { top, left, width, height } = this.position[index]
+
+      if (index === 1) {
+        this.hsb.h = parseInt((360 * x) / width)
+        this.bgcolor = this.HSBToRGB({
+          h: this.hsb.h,
+          s: 100,
+          b: 100
+        })
+        this.setColor()
+      } else {
+        this.rgba.a = (x / width).toFixed(1)
+      }
+      this.setValue(this.rgba)
+    },
+    /**
+     * rgb 转 二进制 hex
+     * @param {Object} rgb
+     */
+    rgbToHex(rgb) {
+      let hex = [rgb.r.toString(16), rgb.g.toString(16), rgb.b.toString(16)]
+      hex.map(function (str, i) {
+        if (str.length == 1) {
+          hex[i] = '0' + str
+        }
+      })
+      return hex.join('')
+    },
+    setColorBySelect(getrgb) {
+      const { r, g, b, a } = getrgb
+      let rgb = {}
+      rgb = {
+        r: r ? parseInt(r) : 0,
+        g: g ? parseInt(g) : 0,
+        b: b ? parseInt(b) : 0,
+        a: a ? a : 0
+      }
+      this.rgba = rgb
+      this.hsb = this.rgbToHsb(rgb)
+      this.changeViewByHsb()
+    },
+    changeViewByHsb() {
+      const [a, b, c] = this.position
+      this.site[0].left = parseInt((this.hsb.s * a.width) / 100)
+      this.site[0].top = parseInt(((100 - this.hsb.b) * a.height) / 100)
+      this.setColor(this.hsb.h)
+      this.setValue(this.rgba)
+      this.bgcolor = this.HSBToRGB({
+        h: this.hsb.h,
+        s: 100,
+        b: 100
+      })
+
+      this.site[1].left = (this.hsb.h / 360) * b.width
+      this.site[2].left = this.rgba.a * c.width
+    },
+    /**
+     * hsb 转 rgb
+     * @param {Object} 颜色模式  H(hues)表示色相,S(saturation)表示饱和度,B(brightness)表示亮度
+     */
+    HSBToRGB(hsb) {
+      let rgb = {}
+      let h = Math.round(hsb.h)
+      let s = Math.round((hsb.s * 255) / 100)
+      let v = Math.round((hsb.b * 255) / 100)
+      if (s == 0) {
+        rgb.r = rgb.g = rgb.b = v
+      } else {
+        let t1 = v
+        let t2 = ((255 - s) * v) / 255
+        let t3 = ((t1 - t2) * (h % 60)) / 60
+        if (h == 360) h = 0
+        if (h < 60) {
+          rgb.r = t1
+          rgb.b = t2
+          rgb.g = t2 + t3
+        } else if (h < 120) {
+          rgb.g = t1
+          rgb.b = t2
+          rgb.r = t1 - t3
+        } else if (h < 180) {
+          rgb.g = t1
+          rgb.r = t2
+          rgb.b = t2 + t3
+        } else if (h < 240) {
+          rgb.b = t1
+          rgb.r = t2
+          rgb.g = t1 - t3
+        } else if (h < 300) {
+          rgb.b = t1
+          rgb.g = t2
+          rgb.r = t2 + t3
+        } else if (h < 360) {
+          rgb.r = t1
+          rgb.g = t2
+          rgb.b = t1 - t3
+        } else {
+          rgb.r = 0
+          rgb.g = 0
+          rgb.b = 0
+        }
+      }
+      return {
+        r: Math.round(rgb.r),
+        g: Math.round(rgb.g),
+        b: Math.round(rgb.b)
+      }
+    },
+    rgbToHsb(rgb) {
+      let hsb = {
+        h: 0,
+        s: 0,
+        b: 0
+      }
+      let min = Math.min(rgb.r, rgb.g, rgb.b)
+      let max = Math.max(rgb.r, rgb.g, rgb.b)
+      let delta = max - min
+      hsb.b = max
+      hsb.s = max != 0 ? (255 * delta) / max : 0
+      if (hsb.s != 0) {
+        if (rgb.r == max) hsb.h = (rgb.g - rgb.b) / delta
+        else if (rgb.g == max) hsb.h = 2 + (rgb.b - rgb.r) / delta
+        else hsb.h = 4 + (rgb.r - rgb.g) / delta
+      } else hsb.h = -1
+      hsb.h *= 60
+      if (hsb.h < 0) hsb.h = 0
+      hsb.s *= 100 / 255
+      hsb.b *= 100 / 255
+      return hsb
+    },
+    getSelectorQuery() {
+      const views = uni.createSelectorQuery().in(this)
+      views
+        .selectAll('.boxs')
+        .boundingClientRect((data) => {
+          if (!data || data.length === 0) {
+            setTimeout(() => this.getSelectorQuery(), 20)
+            return
+          }
+          this.position = data
+          // this.site[0].top = data[0].height;
+          // this.site[0].left = 0;
+          // this.site[1].left = data[1].width;
+          // this.site[2].left = data[2].width;
+          this.setColorBySelect(this.rgba)
+        })
+        .exec()
+    },
+    hex2Rgb(hexColor, alpha = 1) {
+      const color = hexColor.slice(1)
+      const r = parseInt(color.slice(0, 2), 16)
+      const g = parseInt(color.slice(2, 4), 16)
+      const b = parseInt(color.slice(4, 6), 16)
+      return {
+        r: r,
+        g: g,
+        b: b,
+        a: alpha
+      }
+    }
+  },
+  watch: {
+    spareColor(newVal) {
+      this.colorList = newVal
+    },
+    color(newVal) {
+      this.ready()
+    }
+  }
+}
+</script>
+
+<style>
+.t-wrapper {
+  position: fixed;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  width: 100%;
+  box-sizing: border-box;
+  z-index: 9999;
+}
+
+.t-box {
+  width: 100%;
+  position: absolute;
+  bottom: 0;
+  padding: 30upx 0;
+  padding-top: 0;
+  background: #fff;
+  transition: all 0.3s;
+  transform: translateY(100%);
+}
+
+.t-box.active {
+  transform: translateY(0%);
+}
+
+.t-header {
+  display: flex;
+  justify-content: space-between;
+  width: 100%;
+  height: 100upx;
+  border-bottom: 1px #eee solid;
+  box-shadow: 1px 0 2px rgba(0, 0, 0, 0.1);
+  background: #fff;
+}
+
+.t-header-button {
+  display: flex;
+  align-items: center;
+  width: 150upx;
+  height: 100upx;
+  font-size: 30upx;
+  color: #666;
+  padding-left: 20upx;
+}
+
+.t-header-button:last-child {
+  justify-content: flex-end;
+  padding-right: 20upx;
+}
+
+.t-mask {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.6);
+  z-index: -1;
+  transition: all 0.3s;
+  opacity: 0;
+}
+
+.t-mask.active {
+  opacity: 1;
+}
+
+.t-color__box {
+  position: relative;
+  height: 400upx;
+  background: rgb(255, 0, 0);
+  overflow: hidden;
+  box-sizing: border-box;
+  margin: 0 20upx;
+  margin-top: 20upx;
+  box-sizing: border-box;
+}
+
+.t-background {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
+}
+
+.t-color-mask {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  width: 100%;
+  height: 400upx;
+  background: linear-gradient(to top, #000, rgba(0, 0, 0, 0));
+}
+
+.t-pointer {
+  position: absolute;
+  bottom: -8px;
+  left: -8px;
+  z-index: 2;
+  width: 15px;
+  height: 15px;
+  border: 1px #fff solid;
+  border-radius: 50%;
+}
+
+.t-show-color {
+  width: 100upx;
+  height: 50upx;
+}
+
+.t-control__box {
+  margin-top: 50upx;
+  width: 100%;
+  display: flex;
+  padding-left: 20upx;
+  box-sizing: border-box;
+}
+
+.t-control__color {
+  flex-shrink: 0;
+  width: 100upx;
+  height: 100upx;
+  border-radius: 50%;
+  background-color: #fff;
+  background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee),
+    linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee);
+  background-size: 36upx 36upx;
+  background-position: 0 0, 18upx 18upx;
+  border: 1px #eee solid;
+  overflow: hidden;
+}
+
+.t-control__color-content {
+  width: 100%;
+  height: 100%;
+}
+
+.t-control-box__item {
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  width: 100%;
+  padding: 0 30upx;
+}
+
+.t-controller {
+  position: relative;
+  width: 100%;
+  height: 16px;
+  background-color: #fff;
+  background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee),
+    linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee);
+  background-size: 32upx 32upx;
+  background-position: 0 0, 16upx 16upx;
+}
+
+.t-hue {
+  width: 100%;
+  height: 100%;
+  background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
+}
+
+.t-transparency {
+  width: 100%;
+  height: 100%;
+  background: linear-gradient(to right, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0));
+}
+
+.t-circle {
+  position: absolute;
+  /* right: -10px; */
+  top: -2px;
+  width: 20px;
+  height: 20px;
+  box-sizing: border-box;
+  border-radius: 50%;
+  background: #fff;
+  box-shadow: 0 0 2px 1px rgba(0, 0, 0, 0.1);
+}
+
+.t-result__box {
+  margin-top: 20upx;
+  padding: 10upx;
+  width: 100%;
+  display: flex;
+  box-sizing: border-box;
+}
+
+.t-result__item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 10upx;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.t-result__box-input {
+  padding: 10upx 0;
+  width: 100%;
+  font-size: 28upx;
+  box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1);
+  color: #999;
+  text-align: center;
+  background: #fff;
+}
+
+.t-result__box-text {
+  margin-top: 10upx;
+  font-size: 28upx;
+  line-height: 2;
+}
+
+.t-select {
+  flex-shrink: 0;
+  width: 150upx;
+  padding: 0 30upx;
+}
+
+.t-select .t-result__box-input {
+  border-radius: 10upx;
+  border: none;
+  color: #999;
+  box-shadow: 1px 1px 2px 1px rgba(0, 0, 0, 0.1);
+  background: #fff;
+}
+
+.t-select .t-result__box-input:active {
+  box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.1);
+}
+
+.t-alternative {
+  display: flex;
+  flex-wrap: wrap;
+  /* justify-content: space-between; */
+  width: 100%;
+  padding-right: 10upx;
+  box-sizing: border-box;
+}
+
+.t-alternative__item {
+  margin-left: 12upx;
+  margin-top: 10upx;
+  width: 50upx;
+  height: 50upx;
+  border-radius: 10upx;
+  background-color: #fff;
+  background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee),
+    linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%, #eee);
+  background-size: 36upx 36upx;
+  background-position: 0 0, 18upx 18upx;
+  border: 1px #eee solid;
+  overflow: hidden;
+}
+
+.t-alternative__item-content {
+  width: 50upx;
+  height: 50upx;
+  background: rgba(255, 0, 0, 0.5);
+}
+
+.t-alternative__item:active {
+  transition: all 0.3s;
+  transform: scale(1.1);
+}
+</style>

+ 140 - 0
src/uni_modules/sp-editor/components/sp-editor/fab-tool.vue

@@ -0,0 +1,140 @@
+<template>
+  <view class="fab-tool">
+    <view id="toolfab">
+      <slot></slot>
+    </view>
+    <view class="fab-tool-content" :style="placementStyle" id="placementfab">
+      <slot name="content" v-if="visible"></slot>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    visible: {
+      type: Boolean,
+      default: false
+    },
+    placement: {
+      type: String,
+      default: 'auto' // 'auto' | 'top-start' | 'top-center' | 'top-end' | 'bottom-start' | 'bottom-center' | 'bottom-end'
+    }
+  },
+  data() {
+    return {
+      placementHeight: '0',
+      placementType: ''
+    }
+  },
+  watch: {
+    visible(newVal) {
+      if (newVal) {
+        const { screenWidth } = uni.getSystemInfoSync()
+
+        this.$nextTick(() => {
+          let placementWidth = 0
+          uni
+            .createSelectorQuery()
+            .in(this)
+            .select('#placementfab')
+            .boundingClientRect((res) => {
+              this.placementHeight = -res.height + 'px'
+              placementWidth = res.width
+            })
+            .exec()
+          // 开启自动模式后
+          if (this.placement == 'auto') {
+            uni
+              .createSelectorQuery()
+              .in(this)
+              .select('#toolfab')
+              .boundingClientRect((res) => {
+                let leftRemain = res.left
+                let rightRemain = screenWidth - leftRemain
+                if (rightRemain > placementWidth) {
+                  this.placementType = 'bottom-start'
+                } else if (leftRemain > placementWidth) {
+                  this.placementType = 'bottom-end'
+                } else {
+                  this.placementType = 'bottom-center'
+                }
+              })
+              .exec()
+          }
+        })
+      }
+    }
+  },
+  mounted() {
+    this.placementType = this.placement
+  },
+  computed: {
+    placementStyle() {
+      let position = {}
+      switch (this.placementType) {
+        case 'top-start':
+          position = {
+            top: this.placementHeight,
+            left: 0
+          }
+          break
+        case 'top-center':
+          position = {
+            top: this.placementHeight,
+            left: '50%',
+            transform: 'translateX(-50%)'
+          }
+          break
+        case 'top-end':
+          position = {
+            top: this.placementHeight,
+            right: 0
+          }
+          break
+        case 'bottom-start':
+          position = {
+            bottom: this.placementHeight,
+            left: 0
+          }
+          break
+        case 'bottom-center':
+          position = {
+            bottom: this.placementHeight,
+            left: '50%',
+            transform: 'translateX(-50%)'
+          }
+          break
+        case 'bottom-end':
+          position = {
+            bottom: this.placementHeight,
+            right: 0
+          }
+          break
+        default:
+          break
+      }
+      return position
+    }
+  },
+  methods: {
+    //
+  }
+}
+</script>
+
+<style lang="scss">
+.fab-tool {
+  position: relative;
+
+  .fab-tool-content {
+    position: absolute;
+    z-index: 999;
+
+    background-color: #ffffff;
+    box-shadow: -2px -2px 4px rgba(0, 0, 0, 0.05), 2px 2px 4px rgba(0, 0, 0, 0.05);
+    border-radius: 12rpx;
+    box-sizing: border-box;
+  }
+}
+</style>

+ 152 - 0
src/uni_modules/sp-editor/components/sp-editor/link-edit.vue

@@ -0,0 +1,152 @@
+<template>
+  <view class="link-edit-container" v-if="showPopup">
+    <view class="link-edit">
+      <view class="title">添加链接</view>
+      <view class="edit">
+        <view class="description">
+          链接描述:
+          <input v-model="descVal" type="text" class="input" placeholder="请输入链接描述" />
+        </view>
+        <view class="address">
+          链接地址:
+          <input v-model="addrVal" type="text" class="input" placeholder="请输入链接地址" />
+        </view>
+      </view>
+      <view class="control">
+        <view class="cancel" @click="close">取消</view>
+        <view class="confirm" @click="onConfirm">确认</view>
+      </view>
+    </view>
+    <view class="mask"></view>
+  </view>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      showPopup: false,
+      descVal: '',
+      addrVal: ''
+    }
+  },
+  methods: {
+    open() {
+      this.showPopup = true
+      this.$emit('open')
+    },
+    close() {
+      this.showPopup = false
+      this.descVal = ''
+      this.addrVal = ''
+      this.$emit('close')
+    },
+    onConfirm() {
+      if (!this.descVal) {
+        uni.showToast({
+          title: '请输入链接描述',
+          icon: 'none'
+        })
+        return
+      }
+      if (!this.addrVal) {
+        uni.showToast({
+          title: '请输入链接地址',
+          icon: 'none'
+        })
+        return
+      }
+      this.$emit('confirm', {
+        text: this.descVal,
+        href: this.addrVal
+      })
+      this.close()
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+.link-edit-container {
+  .link-edit {
+    width: 80%;
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    background-color: #ffffff;
+    box-shadow: -2px -2px 4px rgba(0, 0, 0, 0.05), 2px 2px 4px rgba(0, 0, 0, 0.05);
+    border-radius: 12rpx;
+    box-sizing: border-box;
+    z-index: 999;
+    font-size: 14px;
+
+    .title {
+      height: 80rpx;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+    }
+
+    .edit {
+      padding: 24rpx;
+      border-top: 1px solid #eeeeee;
+      border-bottom: 1px solid #eeeeee;
+      box-sizing: border-box;
+
+      .input {
+        flex: 1;
+        padding: 4px;
+        font-size: 14px;
+        border: 1px solid #eeeeee;
+        border-radius: 8rpx;
+
+        .uni-input-placeholder {
+          color: #dddddd;
+        }
+      }
+
+      .description {
+        display: flex;
+        align-items: center;
+      }
+      .address {
+        display: flex;
+        align-items: center;
+        margin-top: 24rpx;
+      }
+    }
+
+    .control {
+      height: 80rpx;
+      display: flex;
+      cursor: pointer;
+
+      .cancel {
+        flex: 1;
+        color: #dd524d;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+      }
+      .confirm {
+        border-left: 1px solid #eeeeee;
+        flex: 1;
+        color: #007aff;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+      }
+    }
+  }
+  .mask {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-color: rgba(0, 0, 0, 0.05);
+    z-index: 998;
+  }
+}
+</style>

+ 852 - 0
src/uni_modules/sp-editor/components/sp-editor/sp-editor.vue

@@ -0,0 +1,852 @@
+<template>
+	<view class="sp-editor" :style="{ '--icon-size': iconSize, '--icon-columns': iconColumns }">
+		<view class="sp-editor-toolbar" v-if="!readOnly" @tap="format">
+			<!-- 标题栏 -->
+			<fab-tool v-if="toolbarList.includes('header')" :visible="curFab == 'header'">
+				<view
+					:class="formats.header ? 'ql-active' : ''"
+					class="iconfont icon-header"
+					title="标题"
+					data-name="header"
+					@click.stop="fabTap('header')"
+				></view>
+				<template #content>
+					<view class="fab-tools" @click.stop="fabTapSub($event, 'header')">
+						<view v-for="item in fabTools.header" :key="item.value">
+							<view
+								v-if="toolbarList.includes(item.name)"
+								class="fab-sub"
+								:class="[formats.header === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
+								:title="item.title"
+								data-name="header"
+								:data-value="item.value"
+							></view>
+						</view>
+					</view>
+				</template>
+			</fab-tool>
+			<view
+				v-if="toolbarList.includes('bold')"
+				:class="formats.bold ? 'ql-active' : ''"
+				class="iconfont icon-zitijiacu"
+				title="加粗"
+				data-name="bold"
+			></view>
+			<view
+				v-if="toolbarList.includes('italic')"
+				:class="formats.italic ? 'ql-active' : ''"
+				class="iconfont icon-zitixieti"
+				title="斜体"
+				data-name="italic"
+			></view>
+			<view
+				v-if="toolbarList.includes('underline')"
+				:class="formats.underline ? 'ql-active' : ''"
+				class="iconfont icon-zitixiahuaxian"
+				title="下划线"
+				data-name="underline"
+			></view>
+			<view
+				v-if="toolbarList.includes('strike')"
+				:class="formats.strike ? 'ql-active' : ''"
+				class="iconfont icon-zitishanchuxian"
+				title="删除线"
+				data-name="strike"
+			></view>
+			<!-- 对齐方式 -->
+			<fab-tool v-if="toolbarList.includes('align')" :visible="curFab == 'align'">
+				<view
+					:class="formats.align ? 'ql-active' : ''"
+					class="iconfont icon-zuoyouduiqi"
+					title="对齐方式"
+					data-name="align"
+					@click.stop="fabTap('align')"
+				></view>
+				<template #content>
+					<view class="fab-tools" @click.stop="fabTapSub($event, 'align')">
+						<view v-for="item in fabTools.align" :key="item.value">
+							<view
+								v-if="toolbarList.includes(item.name)"
+								class="fab-sub"
+								:class="[formats.align === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
+								:title="item.title"
+								data-name="align"
+								:data-value="item.value"
+							></view>
+						</view>
+					</view>
+				</template>
+			</fab-tool>
+			<!-- 行间距 -->
+			<fab-tool v-if="toolbarList.includes('lineHeight')" :visible="curFab == 'lineHeight'">
+				<view
+					:class="formats.lineHeight ? 'ql-active' : ''"
+					class="iconfont icon-line-height"
+					title="行间距"
+					data-name="lineHeight"
+					@click.stop="fabTap('lineHeight')"
+				></view>
+				<template #content>
+					<view class="fab-tools" @click.stop="fabTapSub($event, 'lineHeight')">
+						<view v-for="item in fabTools.lineHeight" :key="item.value">
+							<view
+								class="fab-sub"
+								:class="[formats.lineHeight === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
+								:title="item.title"
+								data-name="lineHeight"
+								:data-value="item.value"
+							>
+								{{ item.name }}
+							</view>
+						</view>
+					</view>
+				</template>
+			</fab-tool>
+			<!-- 字间距 -->
+			<fab-tool v-if="toolbarList.includes('letterSpacing')" :visible="curFab == 'letterSpacing'">
+				<view
+					:class="formats.letterSpacing ? 'ql-active' : ''"
+					class="iconfont icon-Character-Spacing"
+					title="字间距"
+					data-name="letterSpacing"
+					@click.stop="fabTap('letterSpacing')"
+				></view>
+				<template #content>
+					<view class="fab-tools" @click.stop="fabTapSub($event, 'letterSpacing')">
+						<view v-for="item in fabTools.space" :key="item.value">
+							<view
+								class="fab-sub"
+								:class="[
+									formats.letterSpacing === item.value ? 'ql-active' : '',
+									item.icon ? 'iconfont' : '',
+									item.icon
+								]"
+								:title="item.title"
+								data-name="letterSpacing"
+								:data-value="item.value"
+							>
+								{{ item.name }}
+							</view>
+						</view>
+					</view>
+				</template>
+			</fab-tool>
+			<!-- 段前距 -->
+			<fab-tool v-if="toolbarList.includes('marginTop')" :visible="curFab == 'marginTop'">
+				<view
+					:class="formats.marginTop ? 'ql-active' : ''"
+					class="iconfont icon-722bianjiqi_duanqianju"
+					title="段前距"
+					data-name="marginTop"
+					@click.stop="fabTap('marginTop')"
+				></view>
+				<template #content>
+					<view class="fab-tools" @click.stop="fabTapSub($event, 'marginTop')">
+						<view v-for="item in fabTools.space" :key="item.value">
+							<view
+								class="fab-sub"
+								:class="[formats.marginTop === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
+								:title="item.title"
+								data-name="marginTop"
+								:data-value="item.value"
+							>
+								{{ item.name }}
+							</view>
+						</view>
+					</view>
+				</template>
+			</fab-tool>
+			<!-- 段后距 -->
+			<fab-tool v-if="toolbarList.includes('marginBottom')" :visible="curFab == 'marginBottom'">
+				<view
+					:class="formats.marginBottom ? 'ql-active' : ''"
+					class="iconfont icon-723bianjiqi_duanhouju"
+					title="段后距"
+					data-name="marginBottom"
+					@click.stop="fabTap('marginBottom')"
+				></view>
+				<template #content>
+					<view class="fab-tools" @click.stop="fabTapSub($event, 'marginBottom')">
+						<view v-for="item in fabTools.space" :key="item.value">
+							<view
+								class="fab-sub"
+								:class="[
+									formats.marginBottom === item.value ? 'ql-active' : '',
+									item.icon ? 'iconfont' : '',
+									item.icon
+								]"
+								:title="item.title"
+								data-name="marginBottom"
+								:data-value="item.value"
+							>
+								{{ item.name }}
+							</view>
+						</view>
+					</view>
+				</template>
+			</fab-tool>
+			<!-- 字体栏 -->
+			<fab-tool v-if="toolbarList.includes('fontFamily')" :visible="curFab == 'fontFamily'">
+				<view
+					:class="formats.fontFamily ? 'ql-active' : ''"
+					class="iconfont icon-font"
+					title="字体"
+					data-name="fontFamily"
+					@click.stop="fabTap('fontFamily')"
+				></view>
+				<template #content>
+					<view class="fab-tools" @click.stop="fabTapSub($event, 'fontFamily')">
+						<view v-for="item in fabTools.fontFamily" :key="item.value">
+							<view
+								class="fab-sub"
+								:class="[formats.fontFamily === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
+								:title="item.title"
+								data-name="fontFamily"
+								:data-value="item.value"
+							>
+								{{ item.name }}
+							</view>
+						</view>
+					</view>
+				</template>
+			</fab-tool>
+			<!-- 字体大小栏 -->
+			<fab-tool v-if="toolbarList.includes('fontSize')" :visible="curFab == 'fontSize'">
+				<view
+					:class="formats.fontSize ? 'ql-active' : ''"
+					class="iconfont icon-fontsize"
+					title="字号"
+					data-name="fontSize"
+					@click.stop="fabTap('fontSize')"
+				></view>
+				<template #content>
+					<view class="fab-tools" @click.stop="fabTapSub($event, 'fontSize')">
+						<view v-for="item in fabTools.fontSize" :key="item.value">
+							<view
+								class="fab-sub"
+								:class="[formats.fontSize === item.value ? 'ql-active' : '', item.icon ? 'iconfont' : '', item.icon]"
+								:title="item.title"
+								data-name="fontSize"
+								:data-value="item.value"
+							>
+								{{ item.name }}
+							</view>
+						</view>
+					</view>
+				</template>
+			</fab-tool>
+			<view
+				v-if="toolbarList.includes('color')"
+				:style="{ color: formats.color ? textColor : 'initial' }"
+				class="iconfont icon-text_color"
+				title="文字颜色"
+				data-name="color"
+				:data-value="textColor"
+			></view>
+			<view
+				v-if="toolbarList.includes('backgroundColor')"
+				:style="{ color: formats.backgroundColor ? backgroundColor : 'initial' }"
+				class="iconfont icon-fontbgcolor"
+				title="背景颜色"
+				data-name="backgroundColor"
+				:data-value="backgroundColor"
+			></view>
+			<view v-if="toolbarList.includes('date')" class="iconfont icon-date" title="日期" @tap="insertDate"></view>
+			<view
+				v-if="toolbarList.includes('listCheck')"
+				class="iconfont icon--checklist"
+				title="待办"
+				data-name="list"
+				data-value="check"
+			></view>
+			<view
+				v-if="toolbarList.includes('listOrdered')"
+				:class="formats.list === 'ordered' ? 'ql-active' : ''"
+				class="iconfont icon-youxupailie"
+				title="有序列表"
+				data-name="list"
+				data-value="ordered"
+			></view>
+			<view
+				v-if="toolbarList.includes('listBullet')"
+				:class="formats.list === 'bullet' ? 'ql-active' : ''"
+				class="iconfont icon-wuxupailie"
+				title="无序列表"
+				data-name="list"
+				data-value="bullet"
+			></view>
+			<view
+				v-if="toolbarList.includes('divider')"
+				class="iconfont icon-fengexian"
+				title="分割线"
+				@click="insertDivider"
+			></view>
+			<view
+				v-if="toolbarList.includes('indentDec')"
+				class="iconfont icon-outdent"
+				title="减少缩进"
+				data-name="indent"
+				data-value="-1"
+			></view>
+			<view
+				v-if="toolbarList.includes('indentInc')"
+				class="iconfont icon-indent"
+				title="增加缩进"
+				data-name="indent"
+				data-value="+1"
+			></view>
+			<view
+				v-if="toolbarList.includes('scriptSub')"
+				:class="formats.script === 'sub' ? 'ql-active' : ''"
+				class="iconfont icon-zitixiabiao"
+				title="下标"
+				data-name="script"
+				data-value="sub"
+			></view>
+			<view
+				v-if="toolbarList.includes('scriptSuper')"
+				:class="formats.script === 'super' ? 'ql-active' : ''"
+				class="iconfont icon-zitishangbiao"
+				title="上标"
+				data-name="script"
+				data-value="super"
+			></view>
+			<view
+				v-if="toolbarList.includes('direction')"
+				:class="formats.direction === 'rtl' ? 'ql-active' : ''"
+				class="iconfont icon-direction-rtl"
+				title="文本方向"
+				data-name="direction"
+				data-value="rtl"
+			></view>
+			<view
+				v-if="toolbarList.includes('image')"
+				class="iconfont icon-charutupian"
+				title="图片"
+				@tap="insertImage"
+			></view>
+			<view v-if="toolbarList.includes('video')" class="iconfont icon-video" title="视频" @tap="insertVideo"></view>
+			<view
+				v-if="toolbarList.includes('link')"
+				class="iconfont icon-charulianjie"
+				title="超链接"
+				@tap="insertLink"
+			></view>
+			<view v-if="toolbarList.includes('undo')" class="iconfont icon-undo" title="撤销" @tap="undo"></view>
+			<view v-if="toolbarList.includes('redo')" class="iconfont icon-redo" title="重做" @tap="redo"></view>
+			<view
+				v-if="toolbarList.includes('removeFormat')"
+				class="iconfont icon-clearedformat"
+				title="清除格式"
+				@tap="removeFormat"
+			></view>
+			<view v-if="toolbarList.includes('clear')" class="iconfont icon-shanchu" title="清空" @tap="clear"></view>
+			<view v-if="toolbarList.includes('export')" class="iconfont icon-baocun" title="导出" @tap="exportHtml"></view>
+		</view>
+
+		<!-- 自定义功能组件 -->
+		<!-- 调色板 -->
+		<color-picker
+			v-if="toolbarList.includes('color') || toolbarList.includes('backgroundColor')"
+			ref="colorPickerRef"
+			:color="defaultColor"
+			@confirm="confirmColor"
+		></color-picker>
+		<!-- 添加链接的操作弹窗 -->
+		<link-edit v-if="toolbarList.includes('link') && !readOnly" ref="linkEditRef" @confirm="confirmLink"></link-edit>
+		<view class="sp-editor-wrapper" @longpress="eLongpress">
+			<editor
+				:id="editorId"
+				class="ql-editor editor-container"
+				:class="{ 'ql-image-overlay-none': readOnly }"
+				show-img-size
+				show-img-toolbar
+				show-img-resize
+				:placeholder="placeholder"
+				:read-only="readOnly"
+				@statuschange="onStatusChange"
+				@ready="onEditorReady"
+				@input="onEditorInput"
+			></editor>
+		</view>
+	</view>
+</template>
+
+<script>
+import ColorPicker from './color-picker.vue'
+import LinkEdit from './link-edit.vue'
+import FabTool from './fab-tool.vue'
+import { addLink, linkFlag } from '../../utils'
+
+export default {
+	components: {
+		ColorPicker,
+		LinkEdit,
+		FabTool
+	},
+	props: {
+		// 编辑器id可传入,以便循环组件使用,防止id重复
+		editorId: {
+			type: String,
+			default: 'editor'
+		},
+		placeholder: {
+			type: String,
+			default: '写点什么吧 ~'
+		},
+		// 是否只读
+		readOnly: {
+			type: Boolean,
+			default: false
+		},
+		// 最大字数限制,-1不限
+		maxlength: {
+			type: Number,
+			default: -1
+		},
+		// 工具栏配置
+		toolbarConfig: {
+			type: Object,
+			default: () => {
+				return {
+					keys: [], // 要显示的工具,优先级最大
+					excludeKeys: [], // 除这些指定的工具外,其他都显示
+					iconSize: '18px', // 工具栏字体大小
+					iconColumns: 10 // 工具栏列数
+				}
+			}
+		}
+	},
+	watch: {
+		toolbarConfig: {
+			deep: true,
+			immediate: true,
+			handler(newToolbar) {
+				/**
+				 * 若工具栏配置中keys存在,则以keys为准
+				 * 否则以excludeKeys向toolbarAllList中排查
+				 * 若keys与excludeKeys皆为空,则以toolbarAllList为准
+				 */
+				if (newToolbar.keys?.length > 0) {
+					this.toolbarList = newToolbar.keys
+				} else {
+					this.toolbarList =
+						newToolbar.excludeKeys?.length > 0
+							? this.toolbarAllList.filter((item) => !newToolbar.excludeKeys.includes(item))
+							: this.toolbarAllList
+				}
+				this.iconSize = newToolbar.iconSize || '18px'
+				this.iconColumns = newToolbar.iconColumns || 10
+			}
+		}
+	},
+	data() {
+		return {
+			formats: {},
+			curFab: '', // 当前悬浮工具栏
+			fabXY: {},
+			textColor: '',
+			backgroundColor: '',
+			curColor: '',
+			defaultColor: { r: 0, g: 0, b: 0, a: 1 }, // 调色板默认颜色
+			iconSize: '20px', // 工具栏图标字体大小
+			iconColumns: 10, // 工具栏列数
+			toolbarList: [],
+			toolbarAllList: [
+				'header', // 标题
+				'H1', // 一级标题
+				'H2', // 二级标题
+				'H3', // 三级标题
+				'H4', // 四级标题
+				'H5', // 五级标题
+				'H6', // 六级标题
+				'bold', // 加粗
+				'italic', // 斜体
+				'underline', // 下划线
+				'strike', // 删除线
+				'align', // 对齐方式
+				'alignLeft', // 左对齐
+				'alignCenter', // 居中对齐
+				'alignRight', // 右对齐
+				'alignJustify', // 两端对齐
+				'lineHeight', // 行间距
+				'letterSpacing', // 字间距
+				'marginTop', // 段前距
+				'marginBottom', // 段后距
+				'fontFamily', // 字体
+				'fontSize', // 字号
+				'color', // 文字颜色
+				'backgroundColor', // 背景颜色
+				'date', // 日期
+				'listCheck', // 待办
+				'listOrdered', // 有序列表
+				'listBullet', // 无序列表
+				'indentInc', // 增加缩进
+				'indentDec', // 减少缩进
+				'divider', // 分割线
+				'scriptSub', // 下标
+				'scriptSuper', // 上标
+				'direction', // 文本方向
+				'image', // 图片
+				'video', // 视频
+				'link', // 超链接
+				'undo', // 撤销
+				'redo', // 重做
+				'removeFormat', // 清除格式
+				'clear', // 清空
+				'export' // 导出
+			],
+			fabTools: {
+				header: [
+					{ title: '一级标题', name: 'H1', value: 1, icon: 'icon-format-header-1' },
+					{ title: '二级标题', name: 'H2', value: 2, icon: 'icon-format-header-2' },
+					{ title: '三级标题', name: 'H3', value: 3, icon: 'icon-format-header-3' },
+					{ title: '四级标题', name: 'H4', value: 4, icon: 'icon-format-header-4' },
+					{ title: '五级标题', name: 'H5', value: 5, icon: 'icon-format-header-5' },
+					{ title: '六级标题', name: 'H6', value: 6, icon: 'icon-format-header-6' }
+				],
+				fontFamily: [
+					{ title: '宋体', name: '宋', value: '宋体', icon: '' },
+					{ title: '黑体', name: '黑', value: '黑体', icon: '' },
+					{ title: '楷体', name: '楷', value: '楷体', icon: '' },
+					{ title: '仿宋', name: '仿', value: '仿宋', icon: '' },
+					{ title: '华文隶书', name: '隶', value: 'STLiti', icon: '' },
+					{ title: '华文行楷', name: '行', value: 'STXingkai', icon: '' },
+					{ title: '幼圆', name: '圆', value: 'YouYuan', icon: '' }
+				],
+				fontSize: [
+					{ title: '12', name: '12', value: '12px', icon: '' },
+					{ title: '14', name: '14', value: '14px', icon: '' },
+					{ title: '16', name: '16', value: '16px', icon: '' },
+					{ title: '18', name: '18', value: '18px', icon: '' },
+					{ title: '20', name: '20', value: '20px', icon: '' },
+					{ title: '22', name: '22', value: '22px', icon: '' },
+					{ title: '24', name: '24', value: '24px', icon: '' }
+				],
+				align: [
+					{ title: '左对齐', name: 'alignLeft', value: 'left', icon: 'icon-zuoduiqi' },
+					{ title: '居中对齐', name: 'alignCenter', value: 'center', icon: 'icon-juzhongduiqi' },
+					{ title: '右对齐', name: 'alignRight', value: 'right', icon: 'icon-youduiqi' },
+					{ title: '两端对齐', name: 'alignJustify', value: 'justify', icon: 'icon-zuoyouduiqi' }
+				],
+				lineHeight: [
+					{ title: '1倍', name: '1', value: '1', icon: '' },
+					{ title: '1.5倍', name: '1.5', value: '1.5', icon: '' },
+					{ title: '2倍', name: '2', value: '2', icon: '' },
+					{ title: '2.5倍', name: '2.5', value: '2.5', icon: '' },
+					{ title: '3倍', name: '3', value: '3', icon: '' }
+				],
+				// 字间距/段前距/段后距
+				space: [
+					{ title: '0.5倍', name: '0.5', value: '0.5em', icon: '' },
+					{ title: '1倍', name: '1', value: '1em', icon: '' },
+					{ title: '1.5倍', name: '1.5', value: '1.5em', icon: '' },
+					{ title: '2倍', name: '2', value: '2em', icon: '' },
+					{ title: '2.5倍', name: '2.5', value: '2.5em', icon: '' },
+					{ title: '3倍', name: '3', value: '3em', icon: '' }
+				]
+			}
+		}
+	},
+	methods: {
+		onEditorReady() {
+			uni
+				.createSelectorQuery()
+				.in(this)
+				.select('#' + this.editorId)
+				.context((res) => {
+					this.editorCtx = res.context
+					this.$emit('init', this.editorCtx, this.editorId)
+				})
+				.exec()
+		},
+		undo() {
+			this.editorCtx.undo()
+		},
+		redo() {
+			this.editorCtx.redo()
+		},
+		format(e) {
+			let { name, value } = e.target.dataset
+			if (!name) return
+			switch (name) {
+				case 'color':
+				case 'backgroundColor':
+					this.curColor = name
+					this.showPicker()
+					break
+				default:
+					this.editorCtx.format(name, value)
+					break
+			}
+		},
+		// 悬浮工具点击
+		fabTap(fabType) {
+			if (this.curFab != fabType) {
+				this.curFab = fabType
+			} else {
+				this.curFab = ''
+			}
+		},
+		// 悬浮工具子集点击
+		fabTapSub(e, fabType) {
+			this.format(e)
+			this.fabTap(fabType)
+		},
+		showPicker() {
+			switch (this.curColor) {
+				case 'color':
+					this.defaultColor = this.textColor
+						? this.$refs.colorPickerRef.hex2Rgb(this.textColor)
+						: { r: 0, g: 0, b: 0, a: 1 }
+					break
+				case 'backgroundColor':
+					this.defaultColor = this.backgroundColor
+						? this.$refs.colorPickerRef.hex2Rgb(this.backgroundColor)
+						: { r: 0, g: 0, b: 0, a: 0 }
+					break
+			}
+			this.$refs.colorPickerRef.open()
+		},
+		confirmColor(e) {
+			switch (this.curColor) {
+				case 'color':
+					this.textColor = e.hex
+					this.editorCtx.format('color', this.textColor)
+					break
+				case 'backgroundColor':
+					this.backgroundColor = e.hex
+					this.editorCtx.format('backgroundColor', this.backgroundColor)
+					break
+			}
+		},
+		onStatusChange(e) {
+			if (e.detail.color) {
+				this.textColor = e.detail.color
+			}
+			if (e.detail.backgroundColor) {
+				this.backgroundColor = e.detail.backgroundColor
+			}
+			this.formats = e.detail
+		},
+		insertDivider() {
+			this.editorCtx.insertDivider()
+		},
+		clear() {
+			uni.showModal({
+				title: '清空编辑器',
+				content: '确定清空编辑器吗?',
+				success: ({ confirm }) => {
+					if (confirm) {
+						this.editorCtx.clear()
+					}
+				}
+			})
+		},
+		removeFormat() {
+			uni.showModal({
+				title: '文本格式化',
+				content: '确定要清除所选择部分文本块格式吗?',
+				showCancel: true,
+				success: ({ confirm }) => {
+					if (confirm) {
+						this.editorCtx.removeFormat()
+					}
+				}
+			})
+		},
+		insertDate() {
+			const date = new Date()
+			const formatDate = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
+			this.editorCtx.insertText({ text: formatDate })
+		},
+		insertLink() {
+			this.$refs.linkEditRef.open()
+		},
+		/**
+		 * 确认添加链接
+		 * @param {Object} e { text: '链接描述', href: '链接地址' }
+		 */
+		confirmLink(e) {
+			this.$refs.linkEditRef.close()
+			addLink(this.editorCtx, e, () => {
+				// 修复添加超链接后,不触发input更新当前最新内容的bug,这里做一下手动更新
+				this.editorCtx.getContents({
+					success: (res) => {
+						this.$emit('input', { html: res.html, text: res.text }, this.editorId)
+					}
+				})
+			})
+			this.$emit('addLink', e, this.editorId)
+		},
+		insertImage() {
+			// #ifdef APP-PLUS || H5
+			uni.chooseImage({
+				// count: 1, // 默认9
+				success: (res) => {
+					const { tempFiles } = res
+					// 将文件和编辑器示例抛出,由开发者自行上传和插入图片
+					this.$emit('upinImage', tempFiles, this.editorCtx, this.editorId)
+				},
+				fail() {
+					uni.showToast({
+						title: '未授权访问相册权限,请授权后使用',
+						icon: 'none'
+					})
+				}
+			})
+			// #endif
+
+			// #ifdef MP-WEIXIN
+			// 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。
+			uni.chooseMedia({
+				// count: 1, // 默认9
+				mediaType: ['image'],
+				success: (res) => {
+					// 同上chooseImage处理
+					const { tempFiles } = res
+					this.$emit('upinImage', tempFiles, this.editorCtx, this.editorId)
+				},
+				fail() {
+					uni.showToast({
+						title: '未授权访问相册权限,请授权后使用',
+						icon: 'none'
+					})
+				}
+			})
+			// #endif
+		},
+		insertVideo() {
+			uni.chooseVideo({
+				sourceType: ['camera', 'album'],
+				success: (res) => {
+					const { tempFilePath } = res
+					// 将文件和编辑器示例抛出,由开发者自行上传和插入图片
+					this.$emit('upinVideo', tempFilePath, this.editorCtx, this.editorId)
+				},
+				fail() {
+					uni.showToast({
+						title: '未授权访问媒体权限,请授权后使用',
+						icon: 'none'
+					})
+				}
+			})
+		},
+		onEditorInput(e) {
+			// 注意不要使用getContents获取html和text,会导致重复触发onStatusChange从而失去toolbar工具的高亮状态
+			// 复制粘贴的时候detail会为空,此时应当直接return
+			if (Object.keys(e.detail).length <= 0) return
+			const { html, text } = e.detail
+			// 识别到标识立即return
+			if (text.indexOf(linkFlag) !== -1) return
+
+			const maxlength = parseInt(this.maxlength)
+			const textStr = text.replace(/[ \t\r\n]/g, '')
+			if (textStr.length > maxlength && maxlength != -1) {
+				uni.showModal({
+					content: `超过${maxlength}字数啦~`,
+					confirmText: '确定',
+					showCancel: false,
+					success: () => {
+						this.$emit('overMax', { html, text }, this.editorId)
+					}
+				})
+			} else {
+				this.$emit('input', { html, text }, this.editorId)
+			}
+		},
+		// 导出
+		exportHtml() {
+			this.editorCtx.getContents({
+				success: (res) => {
+					this.$emit('exportHtml', res.html, this.editorId)
+				}
+			})
+		},
+		eLongpress() {
+			/**
+			 * 微信小程序官方editor的长按事件有bug,需要重写覆盖,不需做任何逻辑,可见下面小程序社区问题链接
+			 * @tutorial https://developers.weixin.qq.com/community/develop/doc/000c04b3e1c1006f660065e4f61000
+			 */
+		}
+	}
+}
+</script>
+
+<style lang="scss">
+@import '@/uni_modules/sp-editor/icons/editor-icon.css';
+@import '@/uni_modules/sp-editor/icons/custom-icon.css';
+
+.sp-editor {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+	position: relative;
+}
+
+.sp-editor-toolbar {
+	box-sizing: border-box;
+	padding: calc(var(--icon-size) / 4) 0;
+	border-bottom: 1px solid #e4e4e4;
+	font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
+	display: grid;
+	grid-template-columns: repeat(var(--icon-columns), 1fr);
+}
+
+.iconfont {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 100%;
+	height: calc(var(--icon-size) * 1.8);
+	cursor: pointer;
+	font-size: var(--icon-size);
+}
+
+.sp-editor-wrapper {
+	flex: 1;
+	overflow: hidden;
+	position: relative;
+}
+
+.editor-container {
+	padding: 8rpx 16rpx;
+	box-sizing: border-box;
+	width: 100%;
+	height: 100%;
+	font-size: 16px;
+	line-height: 1.5;
+}
+
+.ql-image-overlay-none {
+	::v-deep .ql-image-overlay {
+		pointer-events: none;
+		opacity: 0;
+	}
+}
+
+::v-deep .ql-editor.ql-blank::before {
+	font-style: normal;
+	color: #cccccc;
+}
+
+::v-deep .ql-container {
+	min-height: unset;
+}
+
+.ql-active {
+	color: #66ccff;
+}
+
+.fab-tools {
+	display: flex;
+	padding: 0 10rpx;
+	box-sizing: border-box;
+
+	.fab-sub {
+		width: auto;
+		height: auto;
+		margin: 10rpx;
+	}
+}
+</style>

文件差異過大導致無法顯示
+ 24 - 0
src/uni_modules/sp-editor/icons/custom-icon.css


文件差異過大導致無法顯示
+ 238 - 0
src/uni_modules/sp-editor/icons/editor-icon.css


+ 83 - 0
src/uni_modules/sp-editor/package.json

@@ -0,0 +1,83 @@
+{
+	"id": "sp-editor",
+	"displayName": "官方富文本编辑器editor组件改良扩展优化版",
+	"version": "1.5.0",
+	"description": "基于官方的富文本编辑器editor组件,进行改良扩展优化版,添加了调色板,添加超链接等功能,可自定义扩展工具,快来试试吧~",
+	"keywords": [
+        "富文本",
+        "editor",
+        "编辑器"
+    ],
+	"repository": "",
+    "engines": {
+	},
+	"dcloudext": {
+		"type": "component-vue",
+		"sale": {
+			"regular": {
+				"price": "0.00"
+			},
+			"sourcecode": {
+				"price": "0.00"
+			}
+		},
+		"contact": {
+			"qq": ""
+		},
+		"declaration": {
+			"ads": "无",
+			"data": "插件不采集任何数据",
+			"permissions": "无"
+		},
+		"npmurl": ""
+	},
+	"uni_modules": {
+		"dependencies": [],
+		"encrypt": [],
+		"platforms": {
+			"cloud": {
+				"tcb": "y",
+                "aliyun": "y",
+                "alipay": "n"
+			},
+			"client": {
+				"Vue": {
+					"vue2": "y",
+					"vue3": "y"
+				},
+				"App": {
+					"app-vue": "y",
+					"app-nvue": "y"
+				},
+				"H5-mobile": {
+					"Safari": "y",
+					"Android Browser": "y",
+					"微信浏览器(Android)": "y",
+					"QQ浏览器(Android)": "y"
+				},
+				"H5-pc": {
+					"Chrome": "y",
+					"IE": "y",
+					"Edge": "y",
+					"Firefox": "y",
+					"Safari": "y"
+				},
+				"小程序": {
+					"微信": "y",
+					"阿里": "u",
+					"百度": "u",
+					"字节跳动": "u",
+					"QQ": "u",
+					"钉钉": "u",
+					"快手": "u",
+					"飞书": "u",
+					"京东": "u"
+				},
+				"快应用": {
+					"华为": "u",
+					"联盟": "u"
+				}
+			}
+		}
+	}
+}

+ 19 - 0
src/uni_modules/sp-editor/readme.md

@@ -0,0 +1,19 @@
+# sp-editor
+
+### 视频插入功能
+
+`于2024-7-20日v1.4.4版本更新视频插入功能(属实是鸽🕊了太久了)`
+
+- 实现方案:先以图片占位,在导出时,将携带视频链接的图片转换成视频标签。
+- 更多请参考示例一
+- 如果该插件有帮助到您,还望能点赞好评一下,谢谢!🌟
+
+### 文档迁移
+
+> 防止文档失效,提供下列五个地址,内容一致
+
+- [地址一](https://sonvee.github.io/sv-app-docs/docs-github/src/plugins/sp-editor/sp-editor.html)
+- [地址二](https://sv-app-docs.pages.dev/src/plugins/sp-editor/sp-editor.html)
+- [地址三](https://sv-app-docs.4everland.app/src/plugins/sp-editor/sp-editor.html)
+- [地址四](https://sv-app-docs.vercel.app/src/plugins/sp-editor/sp-editor.html) (需要梯子)
+- [地址五](https://static-mp-74bfcbac-6ba6-4f39-8513-8831390ff75a.next.bspapp.com/docs-uni/src/plugins/sp-editor/sp-editor.html) (有IP限制)

文件差異過大導致無法顯示
+ 1 - 0
src/uni_modules/sp-editor/static/image-resize.min.js


文件差異過大導致無法顯示
+ 8 - 0
src/uni_modules/sp-editor/static/quill.min.js


+ 132 - 0
src/uni_modules/sp-editor/utils/index.js

@@ -0,0 +1,132 @@
+// 标识必须独一无二 - 标识是为了使用insertText插入标识文本后,查找到标识所在delta位置的索引
+export const linkFlag = '#-*=*-*=*-*=*@-link超链接标识link-@*=*-*=*-*=*-#'
+
+export function addLink(editorCtx, attr, callback) {
+	// 先插入一段文本内容
+	editorCtx.insertText({
+		text: linkFlag
+	})
+	// 获取全文delta内容
+	editorCtx.getContents({
+		success(res) {
+			let options = res.delta.ops
+			const findex = options.findIndex(item => {
+				return item.insert && typeof item.insert !== 'object' && item.insert?.indexOf(linkFlag) !== -1
+			})
+			// 根据标识查找到插入的位置
+			if (findex > -1) {
+				const findOption = options[findex]
+				const findAttributes = findOption.attributes
+				// 将该findOption分成三部分:前内容 要插入的link 后内容
+				const [prefix, suffix] = findOption.insert.split(linkFlag);
+				const handleOps = []
+				// 前内容
+				if (prefix) {
+					const prefixOps = findAttributes ? {
+						insert: prefix,
+						attributes: findAttributes
+					} : {
+						insert: prefix
+					}
+					handleOps.push(prefixOps)
+				}
+				// 插入的link
+				const linkOps = {
+					insert: attr.text,
+					attributes: {
+						link: attr.href,
+						textDecoration: attr.textDecoration || 'none', // 下划线
+						color: attr.color || '#007aff'
+					}
+				}
+				handleOps.push(linkOps)
+				// 后内容
+				if (suffix) {
+					const suffixOps = findAttributes ? {
+						insert: suffix,
+						attributes: findAttributes
+					} : {
+						insert: suffix
+					}
+					handleOps.push(suffixOps)
+				}
+				// 删除原options[findex]并在findex位置插入上述三个ops
+				options.splice(findex, 1);
+				options.splice(findex, 0, ...handleOps);
+				// 最后重新初始化内容,注意该方法会导致光标重置到最开始位置
+				editorCtx.setContents({
+					delta: {
+						ops: options
+					}
+				})
+				// 所以最后建议使富文本光标失焦,让用户手动聚焦光标
+				editorCtx.blur()
+
+				// 后续回调操作
+				if (callback) callback()
+			}
+		}
+	})
+
+}
+
+/**
+ * 将含有特殊图片形式视频的富文本转换成正常视频的富文本
+ * @param {String} html 要进行处理的富文本字符串
+ * @returns {String} 返回处理结果
+ */
+export function handleHtmlWithVideo(html) {
+	// 正则表达式用于匹配img标签中带有alt属性且alt属性值为视频链接的模式
+	const regex = /<img\s+src="[^"]*"\s+alt="([^"]*)"[^>]*>/g
+	// 使用replace方法和一个函数回调来替换匹配到的内容
+	return html.replace(regex, (match, videoUrl) => {
+		// 替换为video标签,并添加controls属性以便用户可以控制播放
+		return `<video width="80%" controls><source src="${videoUrl}" type="video/mp4"></video>`
+	})
+}
+
+/**
+ * 将img标签中内联style属性中的宽高样式提取出标签width与height属性
+ * @param {Object} html 要处理的富文本字符串
+ * @returns {Object} 返回处理结果
+ */
+export function convertImgStylesToAttributes(html) {
+	return html.replace(/<img\s+([^>]+)\s*>/g, function(match, attributes) {
+		// 分割属性
+		const attrs = attributes.split(/\s+/);
+
+		// 找到style属性的位置
+		const styleIndex = attrs.findIndex(attr => attr.startsWith('style='));
+		if (styleIndex === -1) return match; // 如果没有找到style属性,则返回原样
+
+		// 提取style属性值
+		const styleAttr = attrs.splice(styleIndex, 1)[0];
+		const style = styleAttr.match(/"([^"]*)"/)[1];
+
+		// 解析 style 属性
+		const styleObj = {};
+		style.split(';').forEach(function(part) {
+			if (part) {
+				const [name, value] = part.split(':');
+				styleObj[name.trim()] = value.trim();
+			}
+		});
+
+		// 创建新的 img 标签
+		let newTag = '<img';
+		if (styleObj.width) {
+			newTag += ` width="${styleObj.width}"`;
+		}
+		if (styleObj.height) {
+			newTag += ` height="${styleObj.height}"`;
+		}
+
+		// 添加原有的属性,包括修改过的style属性
+		newTag += ` ${styleAttr} ${attrs.join(' ')}`;
+
+		// 关闭 img 标签
+		newTag += '>';
+
+		return newTag;
+	});
+}