604 lines
16 KiB
Vue
604 lines
16 KiB
Vue
<!--
|
||
Copyright 2024-2026 ReplyPier
|
||
|
||
Licensed under the Apache License, Version 2.0 (the "License");
|
||
you may not use this file except in compliance with the License.
|
||
You may obtain a copy of the License at
|
||
|
||
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
||
Unless required by applicable law or agreed to in writing, software
|
||
distributed under the License is distributed on an "AS IS" BASIS,
|
||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
See the License for the specific language governing permissions and
|
||
limitations under the License.
|
||
-->
|
||
|
||
<script setup lang="ts">
|
||
import { computed } from 'vue'
|
||
|
||
// Define form field interface
|
||
interface FormField {
|
||
name: string
|
||
label: string
|
||
type: string
|
||
required: boolean
|
||
placeholder: string
|
||
options: string | string[] // Can be string (for textarea display) or array (when saved)
|
||
minLength: number
|
||
maxLength: number
|
||
}
|
||
|
||
interface FormNodeData {
|
||
form_title: string
|
||
form_description: string
|
||
submit_button_text: string
|
||
form_full_screen: boolean
|
||
form_fields: FormField[]
|
||
}
|
||
|
||
const props = defineProps<{
|
||
modelValue: FormNodeData
|
||
validationErrors: Record<string, string>
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:model-value', value: FormNodeData): void
|
||
(e: 'validate-field', field: string): void
|
||
}>()
|
||
|
||
const formData = computed({
|
||
get: () => props.modelValue,
|
||
set: (value: FormNodeData) => emit('update:model-value', value)
|
||
})
|
||
|
||
// Get field type icon
|
||
const getFieldTypeIcon = (type: string) => {
|
||
const icons = {
|
||
text: '📝',
|
||
email: '📧',
|
||
number: '🔢',
|
||
tel: '📞',
|
||
textarea: '📄',
|
||
select: '📋',
|
||
checkbox: '☑️',
|
||
radio: '🔘'
|
||
}
|
||
return icons[type as keyof typeof icons] || '📝'
|
||
}
|
||
|
||
// Add form field
|
||
const addFormField = () => {
|
||
const updatedFields = [...formData.value.form_fields, {
|
||
name: '',
|
||
label: '',
|
||
type: 'text',
|
||
required: false,
|
||
placeholder: '',
|
||
options: '',
|
||
minLength: 0,
|
||
maxLength: 255
|
||
}]
|
||
|
||
formData.value = {
|
||
...formData.value,
|
||
form_fields: updatedFields
|
||
}
|
||
|
||
// Trigger validation for form fields
|
||
emit('validate-field', 'form_fields')
|
||
}
|
||
|
||
// Remove form field
|
||
const removeFormField = (index: number) => {
|
||
const updatedFields = formData.value.form_fields.filter((_, i) => i !== index)
|
||
|
||
formData.value = {
|
||
...formData.value,
|
||
form_fields: updatedFields
|
||
}
|
||
|
||
// Trigger validation for form fields
|
||
emit('validate-field', 'form_fields')
|
||
}
|
||
|
||
// Update individual field
|
||
const updateField = (index: number, field: string, value: any) => {
|
||
const updatedFields = [...formData.value.form_fields]
|
||
updatedFields[index] = {
|
||
...updatedFields[index],
|
||
[field]: value
|
||
}
|
||
|
||
formData.value = {
|
||
...formData.value,
|
||
form_fields: updatedFields
|
||
}
|
||
|
||
emit('validate-field', 'form_fields')
|
||
}
|
||
|
||
// Update form data
|
||
const updateFormData = (field: keyof FormNodeData, value: any) => {
|
||
formData.value = {
|
||
...formData.value,
|
||
[field]: value
|
||
}
|
||
|
||
if (field !== 'form_fields') {
|
||
emit('validate-field', field)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="form-node-config">
|
||
<div class="form-group">
|
||
<label for="form-title">表单标题</label>
|
||
<input
|
||
id="form-title"
|
||
:value="formData.form_title"
|
||
@input="updateFormData('form_title', ($event.target as HTMLInputElement).value)"
|
||
type="text"
|
||
class="form-input"
|
||
placeholder="输入表单标题"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="form-description">表单描述</label>
|
||
<textarea
|
||
id="form-description"
|
||
:value="formData.form_description"
|
||
@input="updateFormData('form_description', ($event.target as HTMLTextAreaElement).value)"
|
||
class="form-textarea"
|
||
placeholder="输入表单描述(可选)"
|
||
rows="3"
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="submit-button-text">提交按钮文字</label>
|
||
<input
|
||
id="submit-button-text"
|
||
:value="formData.submit_button_text"
|
||
@input="updateFormData('submit_button_text', ($event.target as HTMLInputElement).value)"
|
||
type="text"
|
||
class="form-input"
|
||
placeholder="提交"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="checkbox-group">
|
||
<input
|
||
:checked="formData.form_full_screen"
|
||
@change="updateFormData('form_full_screen', ($event.target as HTMLInputElement).checked)"
|
||
type="checkbox"
|
||
class="form-checkbox"
|
||
/>
|
||
<span class="checkbox-label">全屏显示表单</span>
|
||
</label>
|
||
<p class="help-text">启用后,表单将以全屏浮层显示,而不是显示在聊天界面内</p>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>表单字段</label>
|
||
<div class="form-fields-container" :class="{ 'error': validationErrors.form_fields }">
|
||
<div
|
||
v-for="(field, index) in formData.form_fields"
|
||
:key="index"
|
||
class="form-field-item"
|
||
>
|
||
<div class="form-field-header">
|
||
<span class="field-type-badge" :class="`field-type-${field.type}`">
|
||
{{ getFieldTypeIcon(field.type) }} {{ field.type }}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
class="remove-field-btn"
|
||
@click="removeFormField(index)"
|
||
title="删除字段"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="form-field-config">
|
||
<div class="field-row">
|
||
<div class="field-col">
|
||
<label class="field-label">字段名 *</label>
|
||
<input
|
||
:value="field.name"
|
||
@input="updateField(index, 'name', ($event.target as HTMLInputElement).value)"
|
||
@blur="$emit('validate-field', 'form_fields')"
|
||
type="text"
|
||
class="field-input"
|
||
placeholder="field_name"
|
||
required
|
||
/>
|
||
</div>
|
||
<div class="field-col">
|
||
<label class="field-label">显示名称 *</label>
|
||
<input
|
||
:value="field.label"
|
||
@input="updateField(index, 'label', ($event.target as HTMLInputElement).value)"
|
||
@blur="$emit('validate-field', 'form_fields')"
|
||
type="text"
|
||
class="field-input"
|
||
placeholder="字段名称"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field-row">
|
||
<div class="field-col">
|
||
<label class="field-label">字段类型</label>
|
||
<select
|
||
:value="field.type"
|
||
@change="updateField(index, 'type', ($event.target as HTMLSelectElement).value)"
|
||
class="field-select"
|
||
>
|
||
<option value="text">文本</option>
|
||
<option value="email">邮箱</option>
|
||
<option value="number">数字</option>
|
||
<option value="tel">电话</option>
|
||
<option value="textarea">多行文本</option>
|
||
<option value="select">下拉选择</option>
|
||
<option value="checkbox">复选框</option>
|
||
<option value="radio">单选框</option>
|
||
</select>
|
||
</div>
|
||
<div class="field-col">
|
||
<label class="field-label">必填</label>
|
||
<label class="checkbox-label">
|
||
<input
|
||
:checked="field.required"
|
||
@change="updateField(index, 'required', ($event.target as HTMLInputElement).checked)"
|
||
type="checkbox"
|
||
class="form-checkbox"
|
||
/>
|
||
<span>必填字段</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field-row">
|
||
<div class="field-col-full">
|
||
<label class="field-label">占位提示</label>
|
||
<input
|
||
:value="field.placeholder"
|
||
@input="updateField(index, 'placeholder', ($event.target as HTMLInputElement).value)"
|
||
@blur="$emit('validate-field', 'form_fields')"
|
||
type="text"
|
||
class="field-input"
|
||
placeholder="输入占位提示文字"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Options for select/radio fields -->
|
||
<div v-if="field.type === 'select' || field.type === 'radio'" class="field-row">
|
||
<div class="field-col-full">
|
||
<label class="field-label">选项(每行一个)</label>
|
||
<textarea
|
||
:value="Array.isArray(field.options) ? field.options.join('\n') : field.options"
|
||
@input="updateField(index, 'options', ($event.target as HTMLTextAreaElement).value)"
|
||
@blur="$emit('validate-field', 'form_fields')"
|
||
class="field-textarea"
|
||
placeholder="选项 1 选项 2 选项 3"
|
||
rows="3"
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Validation for text fields -->
|
||
<div v-if="field.type === 'text' || field.type === 'textarea'" class="field-row">
|
||
<div class="field-col">
|
||
<label class="field-label">最小长度</label>
|
||
<input
|
||
:value="field.minLength"
|
||
@input="updateField(index, 'minLength', parseInt(($event.target as HTMLInputElement).value) || 0)"
|
||
@blur="$emit('validate-field', 'form_fields')"
|
||
type="number"
|
||
class="field-input"
|
||
min="0"
|
||
placeholder="0"
|
||
/>
|
||
</div>
|
||
<div class="field-col">
|
||
<label class="field-label">最大长度</label>
|
||
<input
|
||
:value="field.maxLength"
|
||
@input="updateField(index, 'maxLength', parseInt(($event.target as HTMLInputElement).value) || 255)"
|
||
@blur="$emit('validate-field', 'form_fields')"
|
||
type="number"
|
||
class="field-input"
|
||
min="1"
|
||
placeholder="255"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="add-field-btn"
|
||
@click="addFormField"
|
||
>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
</svg>
|
||
添加表单字段
|
||
</button>
|
||
</div>
|
||
<div v-if="validationErrors.form_fields" class="error-message">
|
||
{{ validationErrors.form_fields }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.form-node-config {
|
||
width: 100%;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: var(--space-sm);
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
color: var(--text-color);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.form-input,
|
||
.form-textarea,
|
||
.form-select {
|
||
width: 100%;
|
||
padding: 8px;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-md);
|
||
background: var(--background-color);
|
||
color: var(--text-color);
|
||
font-size: 0.85rem;
|
||
transition: border-color 0.2s ease;
|
||
}
|
||
|
||
.form-input:focus,
|
||
.form-textarea:focus,
|
||
.form-select:focus {
|
||
outline: none;
|
||
border-color: var(--primary-color);
|
||
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
|
||
}
|
||
|
||
.form-textarea {
|
||
resize: vertical;
|
||
min-height: 70px;
|
||
}
|
||
|
||
.checkbox-group {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
margin: 8px 0;
|
||
}
|
||
|
||
.checkbox-group input[type="checkbox"] {
|
||
margin-top: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.checkbox-label {
|
||
font-size: 0.85rem;
|
||
color: var(--text-color);
|
||
line-height: 1.4;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.form-checkbox {
|
||
width: 16px;
|
||
height: 16px;
|
||
margin: 0;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.help-text {
|
||
font-size: 0.75rem;
|
||
color: var(--text-muted);
|
||
margin-top: 4px;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.error-message {
|
||
color: var(--error-color);
|
||
font-size: 0.75rem;
|
||
margin-top: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.error-message::before {
|
||
content: "⚠";
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
/* Form Fields Styles */
|
||
.form-fields-container {
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-md);
|
||
background: var(--background-color);
|
||
padding: var(--space-sm);
|
||
max-height: none;
|
||
overflow: visible;
|
||
}
|
||
|
||
.form-fields-container.error {
|
||
border-color: var(--error-color);
|
||
background-color: rgba(239, 68, 68, 0.05);
|
||
}
|
||
|
||
.form-field-item {
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-md);
|
||
background: var(--background-soft);
|
||
margin-bottom: var(--space-sm);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.form-field-item:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.form-field-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: var(--space-sm) var(--space-md);
|
||
background: var(--background-muted);
|
||
border-bottom: 1px solid var(--border-color);
|
||
}
|
||
|
||
.field-type-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 2px 6px;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
text-transform: capitalize;
|
||
background: var(--primary-soft);
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.remove-field-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px;
|
||
height: 24px;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.remove-field-btn:hover {
|
||
background: var(--error-color);
|
||
color: white;
|
||
}
|
||
|
||
.remove-field-btn svg {
|
||
width: 12px;
|
||
height: 12px;
|
||
}
|
||
|
||
.form-field-config {
|
||
padding: var(--space-md);
|
||
}
|
||
|
||
.field-row {
|
||
display: flex;
|
||
gap: var(--space-sm);
|
||
margin-bottom: var(--space-sm);
|
||
}
|
||
|
||
.field-row:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.field-col {
|
||
flex: 1;
|
||
}
|
||
|
||
.field-col-full {
|
||
flex: 1;
|
||
width: 100%;
|
||
}
|
||
|
||
.field-label {
|
||
display: block;
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
color: var(--text-muted);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.field-input,
|
||
.field-textarea,
|
||
.field-select {
|
||
width: 100%;
|
||
padding: 6px 8px;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--background-color);
|
||
color: var(--text-color);
|
||
font-size: 0.75rem;
|
||
transition: border-color 0.2s ease;
|
||
}
|
||
|
||
.field-input:focus,
|
||
.field-textarea:focus,
|
||
.field-select:focus {
|
||
outline: none;
|
||
border-color: var(--primary-color);
|
||
box-shadow: 0 0 0 1px rgba(201, 242, 78, 0.15);
|
||
}
|
||
|
||
.field-input.error,
|
||
.field-textarea.error,
|
||
.field-select.error {
|
||
border-color: var(--error-color);
|
||
background-color: rgba(239, 68, 68, 0.05);
|
||
}
|
||
|
||
.field-textarea {
|
||
resize: vertical;
|
||
min-height: 60px;
|
||
}
|
||
|
||
.add-field-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--space-xs);
|
||
width: 100%;
|
||
padding: var(--space-sm);
|
||
background: var(--background-muted);
|
||
border: 1px dashed var(--border-color);
|
||
border-radius: var(--radius-md);
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.add-field-btn:hover {
|
||
background: var(--primary-soft);
|
||
border-color: var(--primary-color);
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.add-field-btn svg {
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
</style> |