图片

项目中这样一个需求:配置一个消息的模板,可输入文字,可插入变量。

在Ant-Design-Vue中,原生的a-textarea组件并不直接支持插入类似标签的特殊节点或变量。但是,我们可以通过一些变通的方法来实现。具体思路如下:使用可编辑 Div 配合隐藏的 a-textarea 模拟文本框。

封装variableTextarea组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
<template>
<div class="variable-textarea-container">
<!-- 可编辑区域 -->
<div
ref="editableDiv"
class="editable-div"
contenteditable="true"
@input="handleInput"
@paste="handlePaste"
@keydown="handleKeydown"
@blur="handleBlur"
@focus="handleFocus"
></div>

<!-- 隐藏的文本域,用于表单提交 -->
<a-textarea v-model:value="internalValue" style="display: none" />

<!-- 变量选择器 -->
<div class="variable-selector">
<span class="selector-label">插入变量:</span>
<a-button
v-for="variable in availableVariables"
:key="variable.name"
size="small"
style="margin-right: 8px; margin-bottom: 4px"
:disabled="internalValue.includes(variable.name)"
@click="insertVariable(variable)"
>
{{ variable.label }}
</a-button>
<div class="tip">{{ countInputValue.length }}/50</div>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';

// 传入属性
const props = defineProps({
modelValue: {
type: String,
default: ''
},
// 可用的变量列表
availableVariables: {
type: Array,
default: () => []
}
});

// 事件定义
const emit = defineEmits(['change', 'focus', 'blur']);

// 响应式数据
const editableDiv = ref(null); // 可编辑的div元素引用
const internalValue = ref(props.modelValue); // 内部存储的文本值
const isFocused = ref(false); // 是否聚焦状态
const countInputValue = ref(''); // 计算纯文本长度

onMounted(() => {
// 初始化时设置可编辑div的innerHTML
updateDivContent();
});
// 监听外部值变化
watch(
() => props.modelValue,
newVal => {
if (newVal !== internalValue.value) {
internalValue.value = newVal;
// updateDivContent();
}
}
);
watch(
() => props.availableVariables.length,
value => {
if (value > 0) {
updateDivContent();
}
}
);

// 监听内部值变化
watch(internalValue, newVal => {
emit('change', newVal);
countInputValue.value = internalValue.value.replace(/\{([^}]+)\}/g, '');
});

// 将纯文本转换为带变量标签的HTML
const parseContentToHTML = text => {
if (!text) return '';

// 匹配 {variable} 格式的变量
const regex = /\{([^}]+)\}/g;
return text.replace(regex, (match, variableName) => {
const variable = props.availableVariables.find(v => v.name === match);
const label = variable ? variable.label : variableName;
return `<span class="variable-tag" contenteditable="false" data-variable="${match}">${label}</span>`;
});
};

// 将HTML内容转换为纯文本
const parseHTMLToText = html => {
if (!html) return '';

const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;

// 遍历所有变量标签,恢复为 {variable} 格式
const variableTags = tempDiv.querySelectorAll('.variable-tag');
variableTags.forEach(tag => {
const variableName = tag.getAttribute('data-variable');
tag.replaceWith(`${variableName}`);
});

return tempDiv.textContent || tempDiv.innerText || '';
};

// 更新可编辑div的内容
const updateDivContent = () => {
if (editableDiv.value) {
editableDiv.value.innerHTML = parseContentToHTML(internalValue.value);
}
};

// 处理输入事件
const handleInput = event => {
const html = event.target.innerHTML;
internalValue.value = parseHTMLToText(html);
};

// 处理粘贴事件(只保留纯文本)
const handlePaste = event => {
event.preventDefault();
const text = event.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
};

// 处理按键事件
const handleKeydown = event => {
// 退格键或删除键处理
if (event.key === 'Backspace' || event.key === 'Delete') {
handleDeleteKey(event);
}
};

// 处理删除键逻辑
const handleDeleteKey = event => {
const selection = window.getSelection();
if (!selection.rangeCount) return;

const range = selection.getRangeAt(0);
const startContainer = range.startContainer;

// 如果光标在变量标签后面,删除整个变量标签
if (startContainer.nodeType === Node.TEXT_NODE && range.startOffset === 0) {
const previousSibling = startContainer.previousSibling;
if (previousSibling && previousSibling.classList.contains('variable-tag')) {
event.preventDefault();
previousSibling.remove();
handleInput({ target: editableDiv.value });
}
}
};

// 处理焦点事件
const handleFocus = event => {
isFocused.value = true;
emit('focus', event);
};

// 处理失焦事件
const handleBlur = event => {
isFocused.value = false;
emit('blur', event);
};

// 插入变量
const insertVariable = async variable => {
if (!editableDiv.value) return;

// 确保可编辑区域有焦点
if (!isFocused.value) {
editableDiv.value.focus();
await nextTick();
}

const variableHTML = `<span class="variable-tag" contenteditable="false" data-variable="${variable.name}">${variable.label}</span>`;

// 获取当前选区
const selection = window.getSelection();

if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);

// 检查选区是否在可编辑区域内
if (!editableDiv.value.contains(range.commonAncestorContainer)) {
// 如果不在,将光标移到末尾
range.selectNodeContents(editableDiv.value);
range.collapse(false);
}

// 删除选区内容(如果有)
range.deleteContents();

// 创建变量节点并插入
const tempDiv = document.createElement('div');
tempDiv.innerHTML = variableHTML;
const variableNode = tempDiv.firstChild;

range.insertNode(variableNode);

// 将光标移动到变量后面
const newRange = document.createRange();
newRange.setStartAfter(variableNode);
newRange.setEndAfter(variableNode);
selection.removeAllRanges();
selection.addRange(newRange);
} else {
// 如果没有选区,则追加到末尾
editableDiv.value.innerHTML += variableHTML;
}

// 触发input事件同步数据
handleInput({ target: editableDiv.value });

// 重新聚焦到可编辑区域
editableDiv.value.focus();
};

// 提供方法给父组件调用
defineExpose({
insertVariable,
focus: () => {
if (editableDiv.value) {
editableDiv.value.focus();
}
}
});
</script>

<style scoped>
.variable-textarea-container {
border: 1px solid #dee0e9;
border-radius: 6px;
background-color: #fff;
transition: all 0.3s;
}

.variable-textarea-container:focus-within {
border-color: #40a9ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}

.editable-div {
min-height: 100px;
max-height: 300px;
padding: 8px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
line-height: 2.5;
font-family: inherit;
font-size: 14px;
}

.editable-div:empty:before {
content: attr(placeholder);
color: #bfbfbf;
}

.variable-selector {
padding: 8px 12px 4px;
border-top: 1px solid #f0f0f0;
background-color: #fafafa;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
position: relative;
}
.variable-selector .tip {
position: absolute;
right: 2px;
top: 2px;
font-size: 12px;
color: #999;
}

.selector-label {
font-size: 14px;
color: #666;
margin-right: 8px;
}

/* 变量标签样式 */
:deep(.variable-tag) {
display: inline-block;
padding: 2px 8px;
margin: 0 2px;
background-color: #e6f7ff;
border: 1px solid #91d5ff;
border-radius: 4px;
color: #1890ff;
font-size: 12px;
line-height: 1.5;
user-select: none;
cursor: default;
vertical-align: middle;
}
</style>

组建使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
<template>
<div class="demo-container">
<h3>带变量的文本框示例</h3>

<div class="form-item">
<label>消息模板:</label>
<VariableTextarea
v-model:modelValue="messageTemplate"
:available-variables="variables"
@change="handleChange"
@focus="handleFocus"
@blur="handleBlur"
/>
</div>

<div class="preview">
<h4>预览效果:</h4>
<div class="preview-content">{{ parsedMessage }}</div>
</div>

<div class="current-value">
<h4>当前值(纯文本):</h4>
<pre>{{ messageTemplate }}</pre>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue';
import VariableTextarea from './components/VariableTextarea.vue';

// 响应式数据
const messageTemplate = ref('亲爱的{用户名},您的订单{订单号}已于{日期}发货。');

// 可用变量列表
const variables = ref([
{ name: '{用户名}', label: '用户名' },
{ name: '{订单号}', label: '订单号' },
{ name: '{日期}', label: '日期' },
{ name: '{金额}', label: '金额' },
{ name: '{地址}', label: '地址' }
]);

// 解析消息模板(模拟真实数据)
const parsedMessage = computed(() => {
return messageTemplate.value
.replace('{用户名}', '张三')
.replace('{订单号}', 'ORD2023123456')
.replace('{日期}', '2023-12-20')
.replace('{金额}', '¥258.00')
.replace('{地址}', '北京市朝阳区');
});

// 事件处理
const handleChange = value => {
console.log('内容变化:', value);
messageTemplate.value = value;
};

const handleFocus = event => {
console.log('获得焦点', event);
};

const handleBlur = event => {
console.log('失去焦点', event);
};
</script>

<style scoped>
.demo-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}

.form-item {
margin-bottom: 20px;
}

.form-item label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}

.preview,
.current-value {
margin-top: 20px;
padding: 15px;
border: 1px solid #f0f0f0;
border-radius: 6px;
background-color: #fafafa;
}

.preview-content {
padding: 10px;
background-color: white;
border-radius: 4px;
min-height: 40px;
}

pre {
background-color: #f5f5f5;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
</style>

功能特点

  1. 变量插入:通过按钮插入预定义的变量标签

  2. 双向绑定:支持 v-model 绑定,与表单系统集成

  3. 纯文本存储:实际存储的是包含 {variable} 格式的纯文本

  4. 视觉反馈:变量在编辑器中显示为特殊标签样式

  5. 完整事件:支持 focus、blur、change 等事件

  6. 快捷键处理:正确处理删除键等操作

实现原理

  1. 使用 contenteditable=”true” 的 div 作为可视化编辑器

  2. 使用隐藏的 a-textarea 存储和同步纯文本数据

  3. 通过正则表达式在纯文本和 HTML 表示之间转换

  4. 变量标签设置为 contenteditable=”false” 防止内部编辑