src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue
@@ -21,12 +21,27 @@
          </el-tag>
        </div>
        <div class="fce-toolbar-actions">
          <el-dropdown trigger="click" @command="applyPreset">
            <el-button size="small">从预设导入</el-button>
          <el-dropdown
            trigger="click"
            :disabled="disableImport"
            @visible-change="onImportDropdownVisible"
            @command="importFromTemplate"
          >
            <el-button size="small" :loading="templateImportLoading" :disabled="disableImport">
              从已有模板导入
            </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 v-if="!templateImportOptions.length" disabled>
                  暂无其他审批模板
                </el-dropdown-item>
                <el-dropdown-item
                  v-for="t in templateImportOptions"
                  :key="t.id"
                  :command="t.id"
                >
                  <span>{{ t.label }}</span>
                  <el-tag v-if="!t.enabled" size="small" type="info" class="import-tag">已停用</el-tag>
                </el-dropdown-item>
              </el-dropdown-menu>
            </template>
@@ -38,7 +53,7 @@
      <el-empty
        v-if="!inner.fields.length"
        class="fce-empty"
        description="暂无填报项,可添加或从预设快速导入"
        description="暂无填报项,可添加或从已有审批模板导入"
        :image-size="72"
      />
@@ -47,7 +62,10 @@
          v-for="(field, index) in inner.fields"
          :key="field._uid"
          class="fce-card"
          :class="{ 'fce-card--required': field.required }"
          :class="{
            'fce-card--required': field.required,
            'fce-card--locked': isFieldLocked(field),
          }"
        >
          <div class="fce-card-badge">{{ index + 1 }}</div>
@@ -56,8 +74,9 @@
              <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>
              <el-tag v-if="isFieldLocked(field)" size="small" type="info" effect="plain">内置项</el-tag>
            </div>
            <div class="fce-card-btns">
            <div v-if="!isFieldLocked(field)" 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>
@@ -90,18 +109,30 @@
                    v-model="field.label"
                    placeholder="如:报销说明"
                    maxlength="50"
                    :disabled="isFieldLocked(field)"
                    @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-input
                    v-model="field.key"
                    placeholder="如:summary"
                    maxlength="50"
                    :disabled="isFieldLocked(field)"
                    @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-select
                    v-model="field.type"
                    style="width: 100%"
                    :disabled="isFieldLocked(field)"
                    @change="onTypeChange(field)"
                  >
                    <el-option
                      v-for="t in FORM_FIELD_TYPE_OPTIONS"
                      :key="t.value"
@@ -124,6 +155,7 @@
                    inline-prompt
                    active-text="必填"
                    inactive-text="选填"
                    :disabled="isFieldLocked(field)"
                    @change="emitOut"
                  />
                </el-form-item>
@@ -136,6 +168,7 @@
                    :max="10"
                    controls-position="right"
                    style="width: 100%"
                    :disabled="isFieldLocked(field)"
                    @change="emitOut"
                  />
                </el-form-item>
@@ -147,6 +180,7 @@
                      v-model="field.min"
                      controls-position="right"
                      style="width: 100%"
                      :disabled="isFieldLocked(field)"
                      @change="emitOut"
                    />
                  </el-form-item>
@@ -159,6 +193,7 @@
                      :max="4"
                      controls-position="right"
                      style="width: 100%"
                      :disabled="isFieldLocked(field)"
                      @change="emitOut"
                    />
                  </el-form-item>
@@ -176,6 +211,7 @@
              :type="field.type === 'textarea' ? 'textarea' : 'text'"
              :rows="field.type === 'textarea' ? 2 : undefined"
              :placeholder="defaultPlaceholder(field)"
              :disabled="isFieldLocked(field)"
              clearable
              @input="emitOut"
            />
@@ -187,6 +223,7 @@
              controls-position="right"
              placeholder="选填"
              style="width: 100%"
              :disabled="isFieldLocked(field)"
              @change="emitOut"
            />
            <el-date-picker
@@ -197,6 +234,7 @@
              format="YYYY-MM-DD"
              value-format="YYYY-MM-DD"
              style="width: 100%"
              :disabled="isFieldLocked(field)"
              clearable
              @change="emitOut"
            />
@@ -210,6 +248,7 @@
              format="YYYY-MM-DD HH:mm:ss"
              value-format="YYYY-MM-DD HH:mm:ss"
              style="width: 100%"
              :disabled="isFieldLocked(field)"
              clearable
              @change="emitOut"
            />
@@ -219,10 +258,13 @@
              placeholder="选填"
              style="width: 100%"
              clearable
              filterable
              :loading="optionSourceLoading"
              :disabled="isFieldLocked(field)"
              @change="emitOut"
            >
              <el-option
                v-for="o in field.options.filter((x) => x.value !== '' && x.value != null)"
                v-for="o in resolvedSelectOptions(field)"
                :key="String(o.value)"
                :label="o.label || o.value"
                :value="o.value"
@@ -231,28 +273,71 @@
          </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>
            <span class="fce-section-title">下拉选项</span>
            <el-row :gutter="16" class="fce-source-row">
              <el-col :span="12">
                <el-form-item label="选项来源" class="fce-field-item">
                  <el-select
                    v-model="field.optionSource"
                    style="width: 100%"
                    :disabled="isFieldLocked(field)"
                    @change="onOptionSourceChange(field)"
                  >
                    <el-option
                      v-for="s in SELECT_OPTION_SOURCE_OPTIONS"
                      :key="s.value"
                      :label="s.label"
                      :value="s.value"
                    />
                  </el-select>
                </el-form-item>
              </el-col>
            </el-row>
            <p v-if="isDynamicOptionSource(field.optionSource)" class="fce-source-tip">
              {{ optionSourceDesc(field.optionSource) }}。提交审批时将自动加载最新数据,无需手动维护选项。
            </p>
            <template v-if="!isDynamicOptionSource(field.optionSource)">
              <div class="fce-options-head">
                <span class="fce-section-subtitle">手动选项</span>
                <el-button
                  type="primary"
                  link
                  size="small"
                  :icon="Plus"
                  :disabled="isFieldLocked(field)"
                  @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="显示文本"
                  :disabled="isFieldLocked(field)"
                  @input="emitOut"
                />
                <el-input
                  v-model="opt.value"
                  placeholder="选项值"
                  class="fce-option-value"
                  :disabled="isFieldLocked(field)"
                  @input="emitOut"
                />
                <el-button
                  type="danger"
                  link
                  :icon="Delete"
                  :disabled="isFieldLocked(field) || field.options.length <= 1"
                  @click="removeOption(field, oi)"
                />
              </div>
            </template>
          </div>
        </div>
      </div>
@@ -262,23 +347,59 @@
<script setup>
import { Bottom, Delete, Plus, Top } from "@element-plus/icons-vue";
import { reactive, watch } from "vue";
import {
  FORM_CONFIG_PRESETS,
  getApprovalTemplateDetail,
  listApprovalTemplate,
  TEMPLATE_TYPE_BUILTIN,
  TEMPLATE_TYPE_CUSTOM,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, reactive, ref, watch } from "vue";
import {
  mapEnabledFromApi,
  unwrapTemplateDetail,
  unwrapTemplateList,
} from "../approveTemplateConstants.js";
import {
  FORM_FIELD_TYPE_OPTIONS,
  applyFormConfigPreset,
  createEmptyFormConfigData,
  createEmptyFormField,
  formFieldTypeLabel,
  parseFormConfigToData,
} from "../formConfigUtils.js";
import {
  SELECT_OPTION_SOURCE,
  SELECT_OPTION_SOURCE_OPTIONS,
  isDynamicOptionSource,
} from "../selectOptionSource.js";
import { useSelectOptionSources } from "../useSelectOptionSources.js";
const props = defineProps({
  modelValue: { type: Object, default: () => createEmptyFormConfigData() },
  /** 编辑当前模板时排除自身,避免从自己导入 */
  excludeTemplateId: { type: [String, Number], default: null },
  /** 禁用「从已有模板导入」 */
  disableImport: { type: Boolean, default: false },
  /** 系统内置模板编辑时,打开弹窗即存在的填报项 _uid,不可改删 */
  lockedFieldUids: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:modelValue"]);
const inner = reactive(createEmptyFormConfigData());
const { loading: optionSourceLoading, ensureForFields, getOptions } = useSelectOptionSources();
const templateImportOptions = ref([]);
const templateImportLoading = ref(false);
const lockedUidSet = computed(
  () => new Set((props.lockedFieldUids || []).filter(Boolean))
);
function isFieldLocked(field) {
  return field?._uid != null && lockedUidSet.value.has(field._uid);
}
function typeLabel(type) {
  return formFieldTypeLabel(type);
@@ -289,6 +410,15 @@
  return `选填,选择模板时将预填${name}`;
}
function optionSourceDesc(source) {
  return SELECT_OPTION_SOURCE_OPTIONS.find((x) => x.value === source)?.desc || "";
}
function resolvedSelectOptions(field) {
  if (field.type !== "select") return [];
  return getOptions(field);
}
function syncFromProps(v) {
  const src = v || createEmptyFormConfigData();
  inner.summaryPlaceholder = src.summaryPlaceholder || "";
@@ -296,8 +426,10 @@
    ...createEmptyFormField(),
    ...f,
    _uid: f._uid || createEmptyFormField()._uid,
    optionSource: f.optionSource || SELECT_OPTION_SOURCE.STATIC,
    options: (f.options || [{ label: "", value: "" }]).map((o) => ({ ...o })),
  }));
  ensureForFields(inner.fields);
}
function emitOut() {
@@ -313,6 +445,7 @@
      min: f.min,
      precision: f.precision,
      defaultValue: cloneDefaultValue(f),
      optionSource: f.optionSource || SELECT_OPTION_SOURCE.STATIC,
      options: (f.options || []).map((o) => ({ label: o.label, value: o.value })),
    })),
  });
@@ -333,17 +466,21 @@
function addField() {
  inner.fields.push(createEmptyFormField());
  ensureForFields(inner.fields);
  emitOut();
}
function removeField(index) {
  if (isFieldLocked(inner.fields[index])) return;
  inner.fields.splice(index, 1);
  emitOut();
}
function moveField(index, delta) {
  if (isFieldLocked(inner.fields[index])) return;
  const next = index + delta;
  if (next < 0 || next >= inner.fields.length) return;
  if (isFieldLocked(inner.fields[next])) return;
  const t = inner.fields[index];
  inner.fields[index] = inner.fields[next];
  inner.fields[next] = t;
@@ -357,10 +494,23 @@
}
function onTypeChange(field) {
  if (field.type === "select" && (!field.options || !field.options.length)) {
    field.options = [{ label: "", value: "" }];
  if (field.type === "select") {
    if (!field.optionSource) field.optionSource = SELECT_OPTION_SOURCE.STATIC;
    if (!field.options || !field.options.length) {
      field.options = [{ label: "", value: "" }];
    }
    ensureForFields(inner.fields);
  }
  resetDefaultValueForType(field);
  emitOut();
}
function onOptionSourceChange(field) {
  field.defaultValue = "";
  if (!isDynamicOptionSource(field.optionSource) && (!field.options || !field.options.length)) {
    field.options = [{ label: "", value: "" }];
  }
  ensureForFields(inner.fields);
  emitOut();
}
@@ -375,10 +525,67 @@
  emitOut();
}
function applyPreset(key) {
  const data = applyFormConfigPreset(key);
  syncFromProps(data);
  emitOut();
async function loadTemplateImportOptions() {
  templateImportLoading.value = true;
  try {
    const [customRes, builtinRes] = await Promise.all([
      listApprovalTemplate(TEMPLATE_TYPE_CUSTOM),
      listApprovalTemplate(TEMPLATE_TYPE_BUILTIN),
    ]);
    const excludeId =
      props.excludeTemplateId != null && props.excludeTemplateId !== ""
        ? String(props.excludeTemplateId)
        : "";
    templateImportOptions.value = [...unwrapTemplateList(customRes), ...unwrapTemplateList(builtinRes)]
      .filter((row) => row?.id != null && String(row.id) !== excludeId)
      .map((row) => ({
        id: row.id,
        label: row.templateName || `模板 #${row.id}`,
        enabled: mapEnabledFromApi(row.enabled),
      }));
  } catch {
    templateImportOptions.value = [];
    ElMessage.error("加载审批模板列表失败");
  } finally {
    templateImportLoading.value = false;
  }
}
function onImportDropdownVisible(visible) {
  if (props.disableImport) return;
  if (visible) loadTemplateImportOptions();
}
async function importFromTemplate(templateId) {
  if (!templateId) return;
  if (inner.fields.length) {
    try {
      await ElMessageBox.confirm("将覆盖当前填报项配置,是否继续?", "从模板导入", {
        type: "warning",
        confirmButtonText: "继续导入",
        cancelButtonText: "取消",
      });
    } catch {
      return;
    }
  }
  templateImportLoading.value = true;
  try {
    const res = await getApprovalTemplateDetail(templateId);
    const row = unwrapTemplateDetail(res);
    const data = parseFormConfigToData(row?.formConfig);
    if (!data.fields?.length) {
      ElMessage.warning("该模板未配置填报项");
      return;
    }
    syncFromProps(data);
    emitOut();
    ElMessage.success(`已导入「${row.templateName || "模板"}」的填报项`);
  } catch {
    ElMessage.error("加载模板详情失败");
  } finally {
    templateImportLoading.value = false;
  }
}
</script>
@@ -436,6 +643,10 @@
  align-items: center;
  gap: 8px;
}
.import-tag {
  margin-left: 8px;
  vertical-align: middle;
}
.fce-empty {
  padding: 24px 0;
@@ -464,6 +675,10 @@
.fce-card--required {
  border-left: 3px solid var(--el-color-danger-light-3);
}
.fce-card--locked {
  background: var(--el-fill-color-light);
}
.fce-card-badge {
@@ -585,10 +800,28 @@
  margin-bottom: 10px;
}
.fce-options-head .fce-section-title {
.fce-options-head .fce-section-title,
.fce-options-head .fce-section-subtitle {
  margin-bottom: 0;
}
.fce-section-subtitle {
  font-size: 12px;
  font-weight: 600;
  color: var(--el-text-color-secondary);
}
.fce-source-row {
  margin-bottom: 4px;
}
.fce-source-tip {
  margin: 0 0 10px;
  font-size: 12px;
  color: var(--el-text-color-secondary);
  line-height: 1.5;
}
.fce-option-row {
  display: flex;
  align-items: center;