<!-- 审批模板:可配置填报项,序列化到 formConfig -->
|
<template>
|
<div class="fce">
|
<div class="fce-hint">
|
<span class="fce-hint-label">填报提示</span>
|
<el-input
|
v-model="inner.summaryPlaceholder"
|
placeholder="如:请填写报销事由、金额等"
|
maxlength="200"
|
show-word-limit
|
@input="emitOut"
|
/>
|
</div>
|
|
<div class="fce-panel">
|
<div class="fce-toolbar">
|
<div class="fce-toolbar-left">
|
<span class="fce-title">填报项配置</span>
|
<el-tag v-if="inner.fields.length" size="small" type="info" effect="plain">
|
共 {{ inner.fields.length }} 项
|
</el-tag>
|
</div>
|
<div class="fce-toolbar-actions">
|
<el-dropdown trigger="click" @command="applyPreset">
|
<el-button size="small">从预设导入</el-button>
|
<template #dropdown>
|
<el-dropdown-menu>
|
<el-dropdown-item v-for="p in FORM_CONFIG_PRESETS" :key="p.key" :command="p.key">
|
{{ p.label }}
|
</el-dropdown-item>
|
</el-dropdown-menu>
|
</template>
|
</el-dropdown>
|
<el-button type="primary" size="small" :icon="Plus" @click="addField">添加填报项</el-button>
|
</div>
|
</div>
|
|
<el-empty
|
v-if="!inner.fields.length"
|
class="fce-empty"
|
description="暂无填报项,可添加或从预设快速导入"
|
:image-size="72"
|
/>
|
|
<div v-else class="fce-list">
|
<div
|
v-for="(field, index) in inner.fields"
|
:key="field._uid"
|
class="fce-card"
|
:class="{ 'fce-card--required': field.required }"
|
>
|
<div class="fce-card-badge">{{ index + 1 }}</div>
|
|
<div class="fce-card-head">
|
<div class="fce-card-title">
|
<span class="fce-card-name">{{ field.label || `填报项 ${index + 1}` }}</span>
|
<el-tag size="small" effect="light" type="primary">{{ typeLabel(field.type) }}</el-tag>
|
<el-tag v-if="field.required" size="small" type="danger" effect="plain">必填</el-tag>
|
</div>
|
<div class="fce-card-btns">
|
<el-tooltip content="上移" placement="top">
|
<el-button circle size="small" :disabled="index === 0" @click="moveField(index, -1)">
|
<el-icon><Top /></el-icon>
|
</el-button>
|
</el-tooltip>
|
<el-tooltip content="下移" placement="top">
|
<el-button
|
circle
|
size="small"
|
:disabled="index >= inner.fields.length - 1"
|
@click="moveField(index, 1)"
|
>
|
<el-icon><Bottom /></el-icon>
|
</el-button>
|
</el-tooltip>
|
<el-tooltip content="删除" placement="top">
|
<el-button circle size="small" type="danger" plain @click="removeField(index)">
|
<el-icon><Delete /></el-icon>
|
</el-button>
|
</el-tooltip>
|
</div>
|
</div>
|
|
<div class="fce-section">
|
<span class="fce-section-title">基础信息</span>
|
<el-row :gutter="16">
|
<el-col :span="8">
|
<el-form-item label="显示名称" required class="fce-field-item">
|
<el-input
|
v-model="field.label"
|
placeholder="如:报销说明"
|
maxlength="50"
|
@input="emitOut"
|
/>
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="字段标识" required class="fce-field-item">
|
<el-input v-model="field.key" placeholder="如:summary" maxlength="50" @input="emitOut" />
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="控件类型" class="fce-field-item">
|
<el-select v-model="field.type" style="width: 100%" @change="onTypeChange(field)">
|
<el-option
|
v-for="t in FORM_FIELD_TYPE_OPTIONS"
|
:key="t.value"
|
:label="t.label"
|
:value="t.value"
|
/>
|
</el-select>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
</div>
|
|
<div class="fce-section">
|
<span class="fce-section-title">校验与格式</span>
|
<el-row :gutter="16" align="middle">
|
<el-col :span="8">
|
<el-form-item label="是否必填" class="fce-field-item fce-field-item--switch">
|
<el-switch
|
v-model="field.required"
|
inline-prompt
|
active-text="必填"
|
inactive-text="选填"
|
@change="emitOut"
|
/>
|
</el-form-item>
|
</el-col>
|
<el-col v-if="field.type === 'textarea'" :span="8">
|
<el-form-item label="行数" class="fce-field-item">
|
<el-input-number
|
v-model="field.rows"
|
:min="1"
|
:max="10"
|
controls-position="right"
|
style="width: 100%"
|
@change="emitOut"
|
/>
|
</el-form-item>
|
</el-col>
|
<template v-if="field.type === 'number'">
|
<el-col :span="8">
|
<el-form-item label="最小值" class="fce-field-item">
|
<el-input-number
|
v-model="field.min"
|
controls-position="right"
|
style="width: 100%"
|
@change="emitOut"
|
/>
|
</el-form-item>
|
</el-col>
|
<el-col :span="8">
|
<el-form-item label="小数位" class="fce-field-item">
|
<el-input-number
|
v-model="field.precision"
|
:min="0"
|
:max="4"
|
controls-position="right"
|
style="width: 100%"
|
@change="emitOut"
|
/>
|
</el-form-item>
|
</el-col>
|
</template>
|
</el-row>
|
</div>
|
|
<div class="fce-section fce-section--default">
|
<span class="fce-section-title">默认值</span>
|
<p class="fce-section-desc">选择该模板提交审批时,将自动预填以下内容(用户仍可修改)</p>
|
<el-input
|
v-if="field.type === 'text' || field.type === 'textarea'"
|
v-model="field.defaultValue"
|
:type="field.type === 'textarea' ? 'textarea' : 'text'"
|
:rows="field.type === 'textarea' ? 2 : undefined"
|
:placeholder="defaultPlaceholder(field)"
|
clearable
|
@input="emitOut"
|
/>
|
<el-input-number
|
v-else-if="field.type === 'number'"
|
v-model="field.defaultValue"
|
:min="field.min"
|
:precision="field.precision ?? 0"
|
controls-position="right"
|
placeholder="选填"
|
style="width: 100%"
|
@change="emitOut"
|
/>
|
<el-date-picker
|
v-else-if="field.type === 'date'"
|
v-model="field.defaultValue"
|
type="date"
|
placeholder="选填"
|
format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
style="width: 100%"
|
clearable
|
@change="emitOut"
|
/>
|
<el-date-picker
|
v-else-if="field.type === 'datetimerange'"
|
v-model="field.defaultValue"
|
type="datetimerange"
|
range-separator="至"
|
start-placeholder="开始时间"
|
end-placeholder="结束时间"
|
format="YYYY-MM-DD HH:mm:ss"
|
value-format="YYYY-MM-DD HH:mm:ss"
|
style="width: 100%"
|
clearable
|
@change="emitOut"
|
/>
|
<el-select
|
v-else-if="field.type === 'select'"
|
v-model="field.defaultValue"
|
placeholder="选填"
|
style="width: 100%"
|
clearable
|
@change="emitOut"
|
>
|
<el-option
|
v-for="o in field.options.filter((x) => x.value !== '' && x.value != null)"
|
:key="String(o.value)"
|
:label="o.label || o.value"
|
:value="o.value"
|
/>
|
</el-select>
|
</div>
|
|
<div v-if="field.type === 'select'" class="fce-section fce-section--options">
|
<div class="fce-options-head">
|
<span class="fce-section-title">下拉选项</span>
|
<el-button type="primary" link size="small" :icon="Plus" @click="addOption(field)">
|
添加选项
|
</el-button>
|
</div>
|
<div
|
v-for="(opt, oi) in field.options"
|
:key="oi"
|
class="fce-option-row"
|
>
|
<span class="fce-option-index">{{ oi + 1 }}</span>
|
<el-input v-model="opt.label" placeholder="显示文本" @input="emitOut" />
|
<el-input v-model="opt.value" placeholder="选项值" class="fce-option-value" @input="emitOut" />
|
<el-button
|
type="danger"
|
link
|
:icon="Delete"
|
:disabled="field.options.length <= 1"
|
@click="removeOption(field, oi)"
|
/>
|
</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<script setup>
|
import { Bottom, Delete, Plus, Top } from "@element-plus/icons-vue";
|
import { reactive, watch } from "vue";
|
import {
|
FORM_CONFIG_PRESETS,
|
FORM_FIELD_TYPE_OPTIONS,
|
applyFormConfigPreset,
|
createEmptyFormConfigData,
|
createEmptyFormField,
|
formFieldTypeLabel,
|
} from "../formConfigUtils.js";
|
|
const props = defineProps({
|
modelValue: { type: Object, default: () => createEmptyFormConfigData() },
|
});
|
|
const emit = defineEmits(["update:modelValue"]);
|
|
const inner = reactive(createEmptyFormConfigData());
|
|
function typeLabel(type) {
|
return formFieldTypeLabel(type);
|
}
|
|
function defaultPlaceholder(field) {
|
const name = field.label || "该字段";
|
return `选填,选择模板时将预填${name}`;
|
}
|
|
function syncFromProps(v) {
|
const src = v || createEmptyFormConfigData();
|
inner.summaryPlaceholder = src.summaryPlaceholder || "";
|
inner.fields = (src.fields || []).map((f) => ({
|
...createEmptyFormField(),
|
...f,
|
_uid: f._uid || createEmptyFormField()._uid,
|
options: (f.options || [{ label: "", value: "" }]).map((o) => ({ ...o })),
|
}));
|
}
|
|
function emitOut() {
|
emit("update:modelValue", {
|
summaryPlaceholder: inner.summaryPlaceholder,
|
fields: inner.fields.map((f) => ({
|
_uid: f._uid,
|
key: f.key,
|
label: f.label,
|
type: f.type,
|
required: f.required,
|
rows: f.rows,
|
min: f.min,
|
precision: f.precision,
|
defaultValue: cloneDefaultValue(f),
|
options: (f.options || []).map((o) => ({ label: o.label, value: o.value })),
|
})),
|
});
|
}
|
|
function cloneDefaultValue(f) {
|
if (f.type === "datetimerange" && Array.isArray(f.defaultValue)) {
|
return [...f.defaultValue];
|
}
|
return f.defaultValue;
|
}
|
|
watch(
|
() => props.modelValue,
|
(v) => syncFromProps(v),
|
{ deep: true, immediate: true }
|
);
|
|
function addField() {
|
inner.fields.push(createEmptyFormField());
|
emitOut();
|
}
|
|
function removeField(index) {
|
inner.fields.splice(index, 1);
|
emitOut();
|
}
|
|
function moveField(index, delta) {
|
const next = index + delta;
|
if (next < 0 || next >= inner.fields.length) return;
|
const t = inner.fields[index];
|
inner.fields[index] = inner.fields[next];
|
inner.fields[next] = t;
|
emitOut();
|
}
|
|
function resetDefaultValueForType(field) {
|
if (field.type === "number") field.defaultValue = undefined;
|
else if (field.type === "datetimerange") field.defaultValue = [];
|
else field.defaultValue = "";
|
}
|
|
function onTypeChange(field) {
|
if (field.type === "select" && (!field.options || !field.options.length)) {
|
field.options = [{ label: "", value: "" }];
|
}
|
resetDefaultValueForType(field);
|
emitOut();
|
}
|
|
function addOption(field) {
|
field.options.push({ label: "", value: "" });
|
emitOut();
|
}
|
|
function removeOption(field, oi) {
|
if (field.options.length <= 1) return;
|
field.options.splice(oi, 1);
|
emitOut();
|
}
|
|
function applyPreset(key) {
|
const data = applyFormConfigPreset(key);
|
syncFromProps(data);
|
emitOut();
|
}
|
</script>
|
|
<style scoped>
|
.fce {
|
width: 100%;
|
}
|
|
.fce-hint {
|
padding: 14px 16px;
|
margin-bottom: 14px;
|
border-radius: 10px;
|
background: linear-gradient(135deg, var(--el-color-primary-light-9) 0%, var(--el-fill-color-blank) 100%);
|
border: 1px solid var(--el-color-primary-light-7);
|
}
|
|
.fce-hint-label {
|
display: block;
|
font-size: 13px;
|
font-weight: 600;
|
color: var(--el-text-color-primary);
|
margin-bottom: 8px;
|
}
|
|
.fce-panel {
|
padding: 16px;
|
border-radius: 12px;
|
background: var(--el-fill-color-lighter);
|
border: 1px solid var(--el-border-color-lighter);
|
}
|
|
.fce-toolbar {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
flex-wrap: wrap;
|
gap: 12px;
|
margin-bottom: 16px;
|
}
|
|
.fce-toolbar-left {
|
display: flex;
|
align-items: center;
|
gap: 10px;
|
}
|
|
.fce-title {
|
font-size: 15px;
|
font-weight: 600;
|
color: var(--el-text-color-primary);
|
}
|
|
.fce-toolbar-actions {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
}
|
|
.fce-empty {
|
padding: 24px 0;
|
}
|
|
.fce-list {
|
display: flex;
|
flex-direction: column;
|
gap: 14px;
|
}
|
|
.fce-card {
|
position: relative;
|
padding: 16px 16px 12px;
|
border-radius: 12px;
|
background: var(--el-bg-color);
|
border: 1px solid var(--el-border-color-lighter);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
transition: border-color 0.2s, box-shadow 0.2s;
|
}
|
|
.fce-card:hover {
|
border-color: var(--el-color-primary-light-5);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
}
|
|
.fce-card--required {
|
border-left: 3px solid var(--el-color-danger-light-3);
|
}
|
|
.fce-card-badge {
|
position: absolute;
|
top: -10px;
|
left: 16px;
|
min-width: 22px;
|
height: 22px;
|
padding: 0 6px;
|
border-radius: 11px;
|
background: var(--el-color-primary);
|
color: #fff;
|
font-size: 12px;
|
font-weight: 700;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
box-shadow: 0 2px 6px rgba(var(--el-color-primary-rgb), 0.35);
|
}
|
|
.fce-card-head {
|
display: flex;
|
align-items: flex-start;
|
justify-content: space-between;
|
gap: 12px;
|
margin-bottom: 14px;
|
padding-top: 4px;
|
}
|
|
.fce-card-title {
|
display: flex;
|
flex-wrap: wrap;
|
align-items: center;
|
gap: 8px;
|
min-width: 0;
|
}
|
|
.fce-card-name {
|
font-size: 14px;
|
font-weight: 600;
|
color: var(--el-text-color-primary);
|
}
|
|
.fce-card-btns {
|
display: flex;
|
align-items: center;
|
gap: 4px;
|
flex-shrink: 0;
|
}
|
|
.fce-section {
|
margin-bottom: 12px;
|
padding-bottom: 12px;
|
border-bottom: 1px dashed var(--el-border-color-extra-light);
|
}
|
|
.fce-section:last-child {
|
margin-bottom: 0;
|
padding-bottom: 0;
|
border-bottom: none;
|
}
|
|
.fce-section-title {
|
display: block;
|
font-size: 12px;
|
font-weight: 600;
|
color: var(--el-text-color-secondary);
|
text-transform: uppercase;
|
letter-spacing: 0.5px;
|
margin-bottom: 10px;
|
}
|
|
.fce-section-desc {
|
margin: -6px 0 10px;
|
font-size: 12px;
|
color: var(--el-text-color-placeholder);
|
line-height: 1.5;
|
}
|
|
.fce-section--default {
|
padding: 12px 14px;
|
border-radius: 8px;
|
background: var(--el-fill-color-lighter);
|
border-bottom: none;
|
margin-bottom: 0;
|
}
|
|
.fce-section--default .fce-section-title {
|
margin-bottom: 4px;
|
color: var(--el-color-primary);
|
text-transform: none;
|
letter-spacing: 0;
|
font-size: 13px;
|
}
|
|
.fce-section--options {
|
padding-top: 4px;
|
border-bottom: none;
|
margin-bottom: 0;
|
}
|
|
.fce-field-item {
|
margin-bottom: 0;
|
}
|
|
.fce-field-item :deep(.el-form-item__label) {
|
font-size: 13px;
|
color: var(--el-text-color-regular);
|
}
|
|
.fce-field-item--switch :deep(.el-form-item__content) {
|
line-height: 32px;
|
}
|
|
.fce-options-head {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
margin-bottom: 10px;
|
}
|
|
.fce-options-head .fce-section-title {
|
margin-bottom: 0;
|
}
|
|
.fce-option-row {
|
display: flex;
|
align-items: center;
|
gap: 10px;
|
margin-bottom: 8px;
|
padding: 8px 10px;
|
border-radius: 8px;
|
background: var(--el-fill-color-lighter);
|
}
|
|
.fce-option-row:last-child {
|
margin-bottom: 0;
|
}
|
|
.fce-option-index {
|
flex-shrink: 0;
|
width: 20px;
|
height: 20px;
|
border-radius: 50%;
|
background: var(--el-color-info-light-8);
|
color: var(--el-text-color-secondary);
|
font-size: 11px;
|
font-weight: 600;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.fce-option-value {
|
width: 140px;
|
flex-shrink: 0;
|
}
|
</style>
|