@xclqmc/base-form 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ <template>
2
+ <!-- 遮罩层 -->
3
+ <transition name="drawer-mask-fade">
4
+ <div v-if="visible" class="drawer-right-mask" :style="{ zIndex: maskZIndex }" @click="onMaskClick"></div>
5
+ </transition>
6
+
7
+ <!-- 抽屉面板 -->
8
+ <transition name="drawer-right-slide">
9
+ <div v-if="visible" class="drawer-right-panel" :style="{ width: props.width, zIndex: panelZIndex }">
10
+ <!-- 头部 -->
11
+ <div class="drawer-right-header">
12
+ <span class="drawer-right-title">{{ props.title }}</span>
13
+ <button class="drawer-right-close-btn" @click="onMaskClick">
14
+ <svg viewBox="0 0 16 16" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
15
+ <path
16
+ d="M4.354 4.354a.5.5 0 0 1 .708 0L8 7.293l2.938-2.939a.5.5 0 0 1 .708.708L8.707 8l2.939 2.938a.5.5 0 0 1-.708.708L8 8.707l-2.938 2.939a.5.5 0 0 1-.708-.708L7.293 8 4.354 5.062a.5.5 0 0 1 0-.708z"
17
+ fill="currentColor" />
18
+ </svg>
19
+ </button>
20
+ </div>
21
+
22
+ <!-- 内容 -->
23
+ <el-scrollbar :max-height="bodyHeight">
24
+ <div :style="{ padding: padding }">
25
+ <slot name="default"></slot>
26
+ </div>
27
+ </el-scrollbar>
28
+
29
+ <!-- 底部 -->
30
+ <div v-if="props.showFooter" class="drawer-right-footer">
31
+ <slot name="footer"> </slot>
32
+ </div>
33
+ </div>
34
+ </transition>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ import { ref, watch, computed } from 'vue';
39
+ import { useZIndex } from 'element-plus';
40
+
41
+ const emit = defineEmits(['update:show', 'close']);
42
+ const props = defineProps({
43
+ show: {
44
+ type: Boolean,
45
+ default: false,
46
+ },
47
+ width: {
48
+ type: String,
49
+ default: '55%',
50
+ },
51
+ title: {
52
+ type: String,
53
+ default: '',
54
+ },
55
+ showFooter: {
56
+ type: Boolean,
57
+ default: false,
58
+ },
59
+ padding: {
60
+ type: String,
61
+ default: '10px 15px 0px 15px',
62
+ },
63
+ closeOnClickModal: {
64
+ type: Boolean,
65
+ default: true,
66
+ },
67
+ });
68
+
69
+ const visible = ref(props.show);
70
+ const { nextZIndex } = useZIndex();
71
+ const maskZIndex = ref(0);
72
+ const panelZIndex = ref(0);
73
+
74
+ // 内容区高度 = 容器高度 - 标题栏 - 底部栏(如果有)
75
+ const bodyHeight = computed(() => {
76
+ const headerHeight = 45;
77
+ const footerHeight = props.showFooter ? 50 : 0;
78
+ return `calc(100vh - ${headerHeight + footerHeight}px)`;
79
+ });
80
+
81
+ // 监听外部 show 变化
82
+ watch(
83
+ () => props.show,
84
+ (newVal) => {
85
+ if (newVal) {
86
+ maskZIndex.value = nextZIndex();
87
+ panelZIndex.value = nextZIndex();
88
+ visible.value = true;
89
+ } else {
90
+ visible.value = false;
91
+ }
92
+ },
93
+ { immediate: true }
94
+ );
95
+
96
+ // 点击遮罩层
97
+ const onMaskClick = () => {
98
+ if (props.closeOnClickModal) {
99
+ emit('close');
100
+ emit('update:show', false);
101
+ }
102
+ };
103
+
104
+ </script>
105
+
106
+ <style lang="scss" scoped>
107
+ // 遮罩层
108
+ .drawer-right-mask {
109
+ position: fixed;
110
+ top: 0;
111
+ left: 0;
112
+ right: 0;
113
+ bottom: 0;
114
+ background: rgba(0, 0, 0, 0.5);
115
+ }
116
+
117
+ // 抽屉面板
118
+ .drawer-right-panel {
119
+ position: fixed;
120
+ top: 0;
121
+ right: 0;
122
+ bottom: 0;
123
+ background: #fff;
124
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
125
+ display: flex;
126
+ flex-direction: column;
127
+ }
128
+
129
+ // 头部
130
+ .drawer-right-header {
131
+ padding: 0 15px;
132
+ height: 45px;
133
+ line-height: 45px;
134
+ border-bottom: 1px solid #eeeeee;
135
+ font-size: 16px;
136
+ color: #606266;
137
+ flex-shrink: 0;
138
+ display: flex;
139
+ align-items: center;
140
+ justify-content: space-between;
141
+ }
142
+
143
+ .drawer-right-title {
144
+ font-weight: 500;
145
+ }
146
+
147
+ .drawer-right-close-btn {
148
+ display: flex;
149
+ align-items: center;
150
+ justify-content: center;
151
+ width: 36px;
152
+ height: 36px;
153
+ padding: 0;
154
+ border: none;
155
+ background: transparent;
156
+ color: #909399;
157
+ cursor: pointer;
158
+ border-radius: 4px;
159
+
160
+ &:hover {
161
+ color: #303133;
162
+ }
163
+ }
164
+
165
+ // 底部
166
+ .drawer-right-footer {
167
+ display: flex;
168
+ align-items: center;
169
+ justify-content: flex-end;
170
+ padding: 0 20px;
171
+ height: 50px;
172
+ border-top: 1px solid #e4e7ed;
173
+ flex-shrink: 0;
174
+ }
175
+
176
+ // 遮罩层过渡动画
177
+ .drawer-mask-fade-enter-active,
178
+ .drawer-mask-fade-leave-active {
179
+ transition: opacity 0.3s ease;
180
+ }
181
+
182
+ .drawer-mask-fade-enter-from,
183
+ .drawer-mask-fade-leave-to {
184
+ opacity: 0;
185
+ }
186
+
187
+ // 抽屉右滑过渡动画
188
+ .drawer-right-slide-enter-active,
189
+ .drawer-right-slide-leave-active {
190
+ transition: transform 0.3s ease;
191
+ }
192
+
193
+ .drawer-right-slide-enter-from,
194
+ .drawer-right-slide-leave-to {
195
+ transform: translateX(100%);
196
+ }
197
+ </style>
@@ -0,0 +1,215 @@
1
+ <template>
2
+ <div
3
+ ref="wrapRef"
4
+ class="flex flex-col border border-br"
5
+ :style="styles"
6
+ @click="(e: any) => emit('click', e)"
7
+ >
8
+ <!-- 仅当容器真实可见(有尺寸)时才挂载编辑器:隐藏即卸载并销毁实例,再次显示重建全新实例,
9
+ 从机制上规避 wangEditor 在隐藏容器中初始化 / 复用已销毁实例导致的"假死 / 无法交互" -->
10
+ <template v-if="mounted">
11
+ <Toolbar class="border-b border-br" :editor="editorRef" :mode="mode" />
12
+ <Editor
13
+ class="flex-1 overflow-y-auto"
14
+ :mode="mode"
15
+ :defaultConfig="state.editorConfig"
16
+ :modelValue="modelValue"
17
+ @onCreated="handleCreated"
18
+ @onChange="handleChange"
19
+ @onDestroyed="handleDestroyed"
20
+ />
21
+ </template>
22
+ </div>
23
+ </template>
24
+
25
+ <script setup lang="ts" name="wngEditor">
26
+ import "@wangeditor/editor/dist/css/style.css";
27
+ import {
28
+ reactive,
29
+ shallowRef,
30
+ watch,
31
+ onMounted,
32
+ onBeforeUnmount,
33
+ computed,
34
+ ref,
35
+ CSSProperties,
36
+ } from "vue";
37
+ // @ts-ignore
38
+ import { IDomEditor } from "@wangeditor/editor";
39
+ import { Toolbar, Editor } from "@wangeditor/editor-for-vue";
40
+
41
+ // 定义父组件传过来的值
42
+ const props = defineProps({
43
+ // 是否禁用
44
+ disable: {
45
+ type: Boolean,
46
+ default: () => false,
47
+ },
48
+ // 内容框默认 placeholder
49
+ placeholder: {
50
+ type: String,
51
+ default: () => "请输入内容...",
52
+ },
53
+ // https://www.wangeditor.com/v5/getting-started.html#mode-%E6%A8%A1%E5%BC%8F
54
+ // 模式,可选 <default|simple>,默认 default
55
+ mode: {
56
+ type: String,
57
+ default: () => "default",
58
+ },
59
+ // 高度
60
+ height: {
61
+ type: String,
62
+ default: () => "310",
63
+ },
64
+ // 宽度
65
+ width: {
66
+ type: String,
67
+ default: () => "100%",
68
+ },
69
+ // 双向绑定,用于获取 editor.modelValue()
70
+ modelValue: {
71
+ type: String,
72
+ default: "",
73
+ },
74
+ // 上传地址
75
+ uploadFileUrl: {
76
+ type: String,
77
+ default: "",
78
+ },
79
+ // 上传请求头(如鉴权 token 由宿主注入:{ Authorization: 'Bearer xxx' })
80
+ headers: {
81
+ type: Object,
82
+ default: () => ({}),
83
+ },
84
+ // 上传接口前缀(默认取宿主环境变量 VITE_API_URL,未配置时走同域相对路径)
85
+ baseURL: {
86
+ type: String,
87
+ default: "/",
88
+ },
89
+ });
90
+
91
+ // 定义子组件向父组件传值/事件(change=内容变化,值为 HTML;click=容器点击,入参为原生事件对象)
92
+ const emit = defineEmits(["update:modelValue", "change", "click"]);
93
+
94
+ //============================================================================数据
95
+ // 是否挂载编辑器:容器真实可见(尺寸 > 0)才为 true,隐藏即置 false 并销毁
96
+ const mounted = ref(false);
97
+ // 编辑器实例(shallowRef:只需持有引用,无需深层响应)
98
+ const editorRef = shallowRef<IDomEditor | null>(null);
99
+ // 容器引用:用 ResizeObserver 监听尺寸判断真实可见性(display:none 的祖先会让尺寸归零)
100
+ const wrapRef = ref<HTMLElement | null>(null);
101
+ // 尺寸监听器
102
+ let resizeObserver: ResizeObserver | null = null;
103
+ // 上传需要的字段信息
104
+ const uploadAttr = reactive({
105
+ fieldName: "file",
106
+ server: props.baseURL + props.uploadFileUrl,
107
+ headers: props.headers,
108
+ meta: {
109
+ isDelete: "0",
110
+ },
111
+ customInsert(res: any, insertFn: any) {
112
+ insertFn(res.data.url);
113
+ },
114
+ });
115
+ // 编辑器配置
116
+ const state = reactive({
117
+ editorConfig: {
118
+ placeholder: props.placeholder,
119
+ MENU_CONF: {
120
+ uploadImage: uploadAttr,
121
+ uploadVideo: uploadAttr,
122
+ },
123
+ },
124
+ });
125
+ // 单位拼接:'310' → '310px','100%' 等自带单位的原样返回
126
+ const addUnit = (value: string | number, unit = "px") => {
127
+ return !Object.is(Number(value), NaN) ? `${value}${unit}` : value;
128
+ };
129
+ // 容器尺寸样式
130
+ const styles = computed<CSSProperties>(() => ({
131
+ height: addUnit(props.height),
132
+ width: addUnit(props.width),
133
+ }));
134
+
135
+ //============================================================================方法
136
+ // 把外部值写入编辑器:内容一致时不写入,避免打断输入 / 触发无谓的 onChange 回环
137
+ const setEditorHtml = (html?: string) => {
138
+ const editor = editorRef.value;
139
+ if (!editor) return;
140
+ const value = html ?? "";
141
+ if (value !== editor.getHtml()) editor.setHtml(value);
142
+ };
143
+
144
+ // 销毁编辑器实例并清空引用(editor-for-vue 不会自动销毁,必须在隐藏 / 卸载时手动销毁)
145
+ const destroyEditor = () => {
146
+ const editor = editorRef.value;
147
+ editorRef.value = null;
148
+ if (editor) editor.destroy();
149
+ };
150
+
151
+ // 容器尺寸变化:真实可见则挂载、隐藏则卸载并销毁
152
+ const handleResize = (entries: ResizeObserverEntry[]) => {
153
+ const rect = entries[0]?.contentRect;
154
+ const visible = !!rect && rect.width > 0 && rect.height > 0;
155
+ if (visible === mounted.value) return;
156
+ mounted.value = visible;
157
+ if (!visible) destroyEditor();
158
+ };
159
+
160
+ // 编辑器创建完成:记录实例、回填最新内容、同步禁用态
161
+ const handleCreated = (editor: IDomEditor) => {
162
+ editorRef.value = editor;
163
+ setEditorHtml(props.modelValue);
164
+ if (props.disable) editor.disable();
165
+ };
166
+
167
+ // 编辑器销毁完成:清空引用
168
+ const handleDestroyed = () => {
169
+ editorRef.value = null;
170
+ };
171
+
172
+ // 编辑器内容变化:回写父组件并派发 change(入参为最新 HTML)
173
+ const handleChange = (editor: IDomEditor) => {
174
+ if (!editorRef.value) return;
175
+ const html = editor.getHtml();
176
+ emit("update:modelValue", html);
177
+ emit("change", html);
178
+ };
179
+
180
+ //============================================================================监听
181
+ // 外部值变化 → 同步进编辑器(覆盖"弹窗打开后异步载入详情"的回显竞态)
182
+ watch(
183
+ () => props.modelValue,
184
+ (val) => setEditorHtml(val),
185
+ );
186
+ // 监听是否禁用改变
187
+ watch(
188
+ () => props.disable,
189
+ (bool) => {
190
+ const editor = editorRef.value;
191
+ if (!editor) return;
192
+ bool ? editor.disable() : editor.enable();
193
+ },
194
+ {
195
+ deep: true,
196
+ },
197
+ );
198
+
199
+ //============================================================================生命周期
200
+ onMounted(() => {
201
+ // 不支持 ResizeObserver 时退化为始终挂载,保证老环境可用
202
+ if (typeof ResizeObserver === "undefined" || !wrapRef.value) {
203
+ mounted.value = true;
204
+ return;
205
+ }
206
+ resizeObserver = new ResizeObserver(handleResize);
207
+ resizeObserver.observe(wrapRef.value);
208
+ });
209
+ // 卸载时断开监听并销毁编辑器,避免残留实例
210
+ onBeforeUnmount(() => {
211
+ resizeObserver?.disconnect();
212
+ resizeObserver = null;
213
+ destroyEditor();
214
+ });
215
+ </script>
@@ -0,0 +1,47 @@
1
+ <template>
2
+ <el-input
3
+ v-model="valueCopy"
4
+ :type="format"
5
+ :placeholder="placeholder"
6
+ :disabled="disabled"
7
+ @change="(v: any) => emit('change', v)"
8
+ @click="(e: any) => emit('click', e)"
9
+ />
10
+ </template>
11
+ <script setup lang="ts">
12
+ /** 文本输入控件(内置字段组件,type='input'):包装 el-input,与 BaseRadio/BaseSelect/BaseEditor 对齐注册进 registry */
13
+ import { computed } from "vue";
14
+
15
+ const emit = defineEmits(["update:modelValue", "change", "click"]);
16
+
17
+ //==============================================================================数据
18
+ const props = defineProps({
19
+ //输入值
20
+ modelValue: {
21
+ type: [String, Number],
22
+ default: null,
23
+ },
24
+ //输入框类型:text/textarea/number 等(BaseForm 里直接取自 field.format)
25
+ format: {
26
+ type: String,
27
+ default: "text",
28
+ },
29
+ //提示信息
30
+ placeholder: {
31
+ type: String,
32
+ default: "请输入",
33
+ },
34
+ //是否禁用
35
+ disabled: {
36
+ type: Boolean,
37
+ default: false,
38
+ },
39
+ });
40
+
41
+ //==============================================================================双向绑定
42
+ // computed 透传:逐键同步(el-input 的 update:modelValue 输入即触发),与直接 v-model 到 el-input 行为一致
43
+ const valueCopy = computed({
44
+ get: () => props.modelValue,
45
+ set: (v) => emit("update:modelValue", v),
46
+ });
47
+ </script>