云
2026-09-17 0558f330f7e3781b91d00ed4a12e60d9fa469d87
src/views/mes/qc/indicatorresult/components/form.vue
@@ -6,10 +6,11 @@
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { MesQcResultValueType } from '@vben/constants';
import { MesQcIndicatorItemType, MesQcResultValueType } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import {
  Button,
  Form as AForm,
  Divider,
  Input,
@@ -36,11 +37,36 @@
  qcType: number;
}
/** 可编辑测量批次(在接口结构上增加数值绑定字段) */
interface EditMeasure {
  detailId?: number;
  resultId?: number;
  measureNo: number;
  value?: string;
  valueNumber?: number;
  remark?: string;
}
/** 可编辑检验项(树形节点) */
interface EditItem {
  indicatorId?: number;
  indicatorName?: string;
  valueType?: number;
  valueSpecification?: string;
  parentId?: number;
  itemType?: number;
  formulaText?: string;
  unitText?: string;
  sortOrder?: number;
  measures: EditMeasure[];
  children?: EditItem[];
}
defineOptions({ name: 'QcIndicatorResultForm' });
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const items = ref<MesQcIndicatorResultApi.IndicatorResultDetail[]>([]);
const roots = ref<EditItem[]>([]);
const ctxData = ref<CtxData>();
const getTitle = computed(() =>
@@ -63,6 +89,19 @@
  wrapperClass: 'grid-cols-2',
});
/** 是否分组项(分组项不录入值,仅承载层级与公式文本) */
function isGroup(item: EditItem) {
  return item.itemType === MesQcIndicatorItemType.GROUP;
}
/** 是否数值型录入(决定 InputNumber 与文本控件) */
function isNumeric(item: EditItem) {
  return (
    item.valueType === MesQcResultValueType.FLOAT ||
    item.valueType === MesQcResultValueType.INTEGER
  );
}
/** 获取字典选项(valueSpecification 为系统字典类型名) */
function getValueOptions(dictType?: string) {
  return dictType
@@ -71,6 +110,75 @@
        value: String(value),
      }))
    : [];
}
/** 将接口返回的明细树转换为可编辑结构:分组递归、录入项保证至少一个测量批次 */
function toEditItems(
  items: MesQcIndicatorResultApi.IndicatorResultDetail[] = [],
): EditItem[] {
  return items.map((item) => {
    const node: EditItem = {
      indicatorId: item.indicatorId,
      indicatorName: item.indicatorName,
      valueType: item.valueType,
      valueSpecification: item.valueSpecification,
      parentId: item.parentId,
      itemType: item.itemType,
      formulaText: item.formulaText,
      unitText: item.unitText,
      sortOrder: item.sortOrder,
      measures: [],
      children: item.children ? toEditItems(item.children) : undefined,
    };
    if (isGroup(node)) {
      return node;
    }
    const measures = (item.measures ?? []).map((m) => ({
      detailId: m.detailId,
      resultId: m.resultId,
      measureNo: m.measureNo ?? 1,
      value: m.value,
      valueNumber:
        isNumeric(node) && m.value !== null && m.value !== undefined
          ? Number(m.value)
          : undefined,
      remark: m.remark,
    }));
    node.measures = measures.length > 0 ? measures : [{ measureNo: 1 }];
    return node;
  });
}
/** 收集所有录入项(叶子)用于提交与展示统计 */
function collectLeaves(nodes: EditItem[]): EditItem[] {
  const leaves: EditItem[] = [];
  const walk = (list: EditItem[]) => {
    list.forEach((n) => {
      if (isGroup(n)) {
        walk(n.children ?? []);
      } else {
        leaves.push(n);
      }
    });
  };
  walk(nodes);
  return leaves;
}
/** 新增一个测量批次 */
function addMeasure(item: EditItem) {
  const nextNo =
    item.measures.reduce((max, m) => Math.max(max, m.measureNo ?? 0), 0) + 1;
  item.measures.push({ measureNo: nextNo });
}
/** 删除指定测量批次(至少保留一个) */
function removeMeasure(item: EditItem, index: number) {
  if (item.measures.length <= 1) {
    message.warning('至少保留一个测量批次');
    return;
  }
  item.measures.splice(index, 1);
}
const [Modal, modalApi] = useVbenModal({
@@ -83,25 +191,26 @@
      return;
    }
    modalApi.lock();
    // 提交表单
    const head =
      (await formApi.getValues()) as MesQcIndicatorResultApi.IndicatorResult;
    const submitItems = items.value.map((item) => {
      const submit: MesQcIndicatorResultApi.IndicatorResultDetail = {
        id: item.id,
        indicatorId: item.indicatorId,
        remark: item.remark,
      };
      if (
        item.valueType === MesQcResultValueType.FLOAT ||
        item.valueType === MesQcResultValueType.INTEGER
      ) {
        submit.value =
          item.valueNumber === null ? undefined : String(item.valueNumber);
      } else {
        submit.value = item.value;
      }
      return submit;
    // 展开:每个录入项的每个测量批次 → 一条明细
    const submitItems: MesQcIndicatorResultApi.IndicatorResultDetail[] = [];
    collectLeaves(roots.value).forEach((item) => {
      item.measures.forEach((measure) => {
        const value = isNumeric(item)
          ? measure.valueNumber === null || measure.valueNumber === undefined
            ? undefined
            : String(measure.valueNumber)
          : measure.value;
        submitItems.push({
          id: measure.detailId,
          resultId: measure.resultId,
          indicatorId: item.indicatorId,
          measureNo: measure.measureNo,
          value,
          remark: measure.remark,
        });
      });
    });
    const payload: MesQcIndicatorResultApi.IndicatorResult = {
      ...head,
@@ -117,7 +226,6 @@
        await createIndicatorResult(payload);
        message.success($t('common.createSuccess'));
      }
      // 关闭并提示
      await modalApi.close();
      emit('success');
    } finally {
@@ -127,11 +235,10 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      ctxData.value = undefined;
      items.value = [];
      roots.value = [];
      return;
    }
    formApi.setState({ schema: useQcIndicatorResultFormSchema(formApi) });
    // 加载数据
    const data = modalApi.getData<CtxData>();
    ctxData.value = data;
    formType.value = data.formType;
@@ -142,17 +249,7 @@
        data.qcType,
        data.id,
      );
      // 回填数值字段(用于 InputNumber 绑定)
      items.value = (detail.items ?? []).map((item) => ({
        ...item,
        valueNumber:
          (item.valueType === MesQcResultValueType.FLOAT ||
            item.valueType === MesQcResultValueType.INTEGER) &&
          item.value !== null
            ? Number(item.value)
            : undefined,
      }));
      // 设置到 values
      roots.value = toEditItems(detail.items);
      await formApi.setValues({
        id: detail.id,
        code: detail.code,
@@ -164,6 +261,18 @@
    }
  },
});
/** 单个测量值输入控件绑定(数值型走 valueNumber,其余走 value) */
function bindMeasure(item: EditItem, measure: EditMeasure) {
  return isNumeric(item) ? measure.valueNumber : measure.value;
}
function onMeasureInput(item: EditItem, measure: EditMeasure, v: any) {
  if (isNumeric(item)) {
    measure.valueNumber = v === null ? undefined : Number(v);
  } else {
    measure.value = v;
  }
}
</script>
<template>
@@ -171,48 +280,182 @@
    <div class="px-4">
      <Form />
      <Divider>检测值</Divider>
      <!-- 检测值控件由每行 valueType 驱动,逐行渲染:FLOAT/INTEGER 走 InputNumber,DICT 走 Select 并按 valueSpecification 解析选项 -->
      <AForm :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
        <div
          v-for="(item, index) in items"
          :key="item.indicatorId ?? index"
          class="mb-2"
        >
          <AForm.Item
            :label="`检测项${index + 1}:${item.indicatorName ?? ''}`"
          >
            <InputNumber
              v-if="
                item.valueType === MesQcResultValueType.FLOAT ||
                item.valueType === MesQcResultValueType.INTEGER
              "
              v-model:value="item.valueNumber"
              :precision="item.valueType === MesQcResultValueType.FLOAT ? 4 : 0"
              class="!w-full"
              placeholder="请输入"
            />
            <Textarea
              v-else-if="item.valueType === MesQcResultValueType.TEXT"
              v-model:value="item.value"
              :rows="2"
              placeholder="请输入检测值"
            />
            <Select
              v-else-if="item.valueType === MesQcResultValueType.DICT"
              v-model:value="item.value"
              allow-clear
              :options="getValueOptions(item.valueSpecification)"
              placeholder="请选择"
            />
            <Input
              v-else-if="item.valueType === MesQcResultValueType.FILE"
              v-model:value="item.value"
              placeholder="请输入文件地址"
            />
            <Input v-else v-model:value="item.value" placeholder="请输入" />
          </AForm.Item>
        </div>
      </AForm>
      <div v-if="roots.length === 0" class="py-4 text-center text-gray-400">
        该质检单未配置检测指标
      </div>
      <div v-else class="qc-result-tree">
        <template v-for="(node, ni) in roots" :key="node.indicatorId ?? ni">
          <!-- 分组项:标题行 + 公式文本,下挂子录入项 -->
          <div v-if="isGroup(node)">
            <div
              class="mb-2 flex items-center justify-between rounded bg-gray-100 px-3 py-1.5 font-medium"
            >
              <span>{{ node.indicatorName }}</span>
              <span v-if="node.formulaText" class="text-xs text-gray-500">
                {{ node.formulaText }}
              </span>
            </div>
            <div
              v-for="(child, ci) in node.children ?? []"
              :key="child.indicatorId ?? ci"
              class="mb-3 ml-4"
            >
              <div class="mb-1 flex items-center justify-between">
                <span class="font-medium">
                  {{ child.indicatorName }}
                  <span v-if="child.unitText" class="text-xs text-gray-500">
                    ({{ child.unitText }})
                  </span>
                </span>
                <Button
                  size="small"
                  type="link"
                  @click="addMeasure(child)"
                >
                  + 测量批次
                </Button>
              </div>
              <div
                v-for="(measure, mi) in child.measures"
                :key="mi"
                class="mb-1 flex items-center gap-2"
              >
                <span class="w-16 shrink-0 text-right text-xs text-gray-500">
                  第 {{ measure.measureNo }} 次
                </span>
                <InputNumber
                  v-if="child.valueType === MesQcResultValueType.FLOAT || child.valueType === MesQcResultValueType.INTEGER"
                  :value="bindMeasure(child, measure)"
                  :precision="child.valueType === MesQcResultValueType.FLOAT ? 4 : 0"
                  class="!w-48"
                  placeholder="请输入"
                  @update:value="(v) => onMeasureInput(child, measure, v)"
                />
                <Textarea
                  v-else-if="child.valueType === MesQcResultValueType.TEXT"
                  v-model:value="measure.value"
                  :rows="1"
                  class="!w-48"
                  placeholder="请输入检测值"
                />
                <Select
                  v-else-if="child.valueType === MesQcResultValueType.DICT"
                  v-model:value="measure.value"
                  allow-clear
                  class="!w-48"
                  :options="getValueOptions(child.valueSpecification)"
                  placeholder="请选择"
                />
                <Input
                  v-else-if="child.valueType === MesQcResultValueType.FILE"
                  v-model:value="measure.value"
                  class="!w-48"
                  placeholder="请输入文件地址"
                />
                <Input
                  v-else
                  v-model:value="measure.value"
                  class="!w-48"
                  placeholder="请输入"
                />
                <Input
                  v-model:value="measure.remark"
                  class="flex-1"
                  placeholder="备注"
                />
                <Button
                  size="small"
                  type="text"
                  danger
                  :disabled="child.measures.length <= 1"
                  @click="removeMeasure(child, mi)"
                >
                  删除
                </Button>
              </div>
            </div>
          </div>
          <!-- 顶级录入项 -->
          <div v-else class="mb-3">
            <div class="mb-1 flex items-center justify-between">
              <span class="font-medium">
                {{ node.indicatorName }}
                <span v-if="node.unitText" class="text-xs text-gray-500">
                  ({{ node.unitText }})
                </span>
              </span>
              <Button size="small" type="link" @click="addMeasure(node)">
                + 测量批次
              </Button>
            </div>
            <div
              v-for="(measure, mi) in node.measures"
              :key="mi"
              class="mb-1 flex items-center gap-2"
            >
              <span class="w-16 shrink-0 text-right text-xs text-gray-500">
                第 {{ measure.measureNo }} 次
              </span>
              <InputNumber
                v-if="node.valueType === MesQcResultValueType.FLOAT || node.valueType === MesQcResultValueType.INTEGER"
                :value="bindMeasure(node, measure)"
                :precision="node.valueType === MesQcResultValueType.FLOAT ? 4 : 0"
                class="!w-48"
                placeholder="请输入"
                @update:value="(v) => onMeasureInput(node, measure, v)"
              />
              <Textarea
                v-else-if="node.valueType === MesQcResultValueType.TEXT"
                v-model:value="measure.value"
                :rows="1"
                class="!w-48"
                placeholder="请输入检测值"
              />
              <Select
                v-else-if="node.valueType === MesQcResultValueType.DICT"
                v-model:value="measure.value"
                allow-clear
                class="!w-48"
                :options="getValueOptions(node.valueSpecification)"
                placeholder="请选择"
              />
              <Input
                v-else-if="node.valueType === MesQcResultValueType.FILE"
                v-model:value="measure.value"
                class="!w-48"
                placeholder="请输入文件地址"
              />
              <Input
                v-else
                v-model:value="measure.value"
                class="!w-48"
                placeholder="请输入"
              />
              <Input
                v-model:value="measure.remark"
                class="flex-1"
                placeholder="备注"
              />
              <Button
                size="small"
                type="text"
                danger
                :disabled="node.measures.length <= 1"
                @click="removeMeasure(node, mi)"
              >
                删除
              </Button>
            </div>
          </div>
        </template>
      </div>
    </div>
  </Modal>
</template>
<style scoped>
.qc-result-tree {
  max-height: 60vh;
  overflow-y: auto;
}
</style>