行内编辑,前端如何兜底?😭😭😭

122 阅读3分钟

情形

不知道大家有没有遇到过这种情况:

给 Table 增加行内编辑功能后,页面一加载完成,就弹出一堆「更新 xxx 失败」的提示。

排查后发现,问题可能出在前后端字段不一致上:

后端接口返回的数据中,某个字段的值为 null,前端的 el-switch 又绑定了这个字段,导致组件初始化时触发了更新请求。(undefined 不会触发,但即使没有报错,也不代表处理逻辑正确。)

行内编辑不仅要考虑用户修改数据时的更新逻辑,还要处理好数据初始化时的异常情况。

尤其要注意:

字段缺失、null 和 空字符串 并不一定代表相同的业务含义,不能随意将它们转换成默认值,更不能在用户未操作的情况下触发更新请求。

解决 (el-switch)

useRowSwitch.js

import { reactive } from "vue";
import { ElMessage } from "element-plus";
import { get, set } from "lodash-es";
import { promise2bool } from "@/utils/async.js";

/**
 * @description 行内 el-switch 编辑的统一收口(受控模式)
 *
 * 核心原则:开关的视觉状态永远由数据驱动,row[field] 只在后端确认成功后才更新。
 * 取消 / 失败 / 超时 / 后端未返回字段时,UI 保持旧值,且只影响当前行当前字段,不动其他行。
 *
 * @example
 * // 基础使用(1/0 值)
 * const { getSwitchProps } = useRowSwitch({
 *   field: "cancel",
 *   label: "是否作废",
 *   confirm: (row, newValue, oldValue) =>
 *     ElMessageBox.confirm(
 *       `确定要将状态从「${oldValue}」修改为「${newValue}」吗?`,
 *       "提示",
 *       { type: "warning" }
 *     ),
 *   fetch: ({ row, newValue }) =>
 *     updateExperimentOrderStatus({ experimentOrderId: row.experimentOrderId, cancelStatus: newValue }),
 *   successMsg: "修改成功"
 * });
 *
 * // template 中(表格行内有点击事件时记得 @click.stop)
 * <el-table-column label="是否作废">
 *   <template #default="{ row }">
 *     <el-switch v-bind="getSwitchProps(row)" inline-prompt active-text="是" inactive-text="否" @click.stop />
 *   </template>
 * </el-table-column>
 *
 * @example
 * // 字符串枚举值(如 RuoYi 的 status: "0"/"1")
 * const { getSwitchProps } = useRowSwitch({
 *   field: "status",
 *   activeValue: "0",
 *   inactiveValue: "1",
 *   fetch: ({ row, newValue }) => changeUserStatus(row.userId, newValue)
 * });
 *
 * @param {Object} options
 * @param {string} options.field - row 上要切换的字段名(必填),支持嵌套点路径如 "resultData.isFullyOrdered"
 * @param {Function} options.fetch - 发起修改请求(必填),入参 ({ row, newValue, oldValue }),返回 Promise
 * @param {any} [options.activeValue=1] - 打开时的值
 * @param {any} [options.inactiveValue=0] - 关闭时的值
 * @param {Function} [options.confirm] - 确认弹窗,入参 (row, newValue, oldValue),resolve / 返回 true 才继续
 * @param {Function} [options.beforeSubmit] - 额外前置校验,入参 (row, newValue, oldValue),返回 false 则中断(在 confirm 之前执行)
 * @param {string|Function} [options.successMsg] - 成功提示,字符串或 (row, newValue) => string
 * @param {Function} [options.onSuccess] - 成功回调 (row, newValue, oldValue, res),可用来覆盖后端返回的权威值
 * @param {Function} [options.onError] - 失败回调 (e, row);request 拦截器已统一 toast 时可不传
 * @param {string} [options.label] - 字段中文名,用于「后端未返回字段」的报错文案
 * @param {string} [options.nilMsg] - 覆盖「后端未返回字段」的默认报错文案
 * @param {Function} [options.isDisabled] - (row) => boolean,按行禁用开关
 * @returns {Object} returns
 * @returns {Function} returns.getSwitchProps - 生成 el-switch 的受控 props,直接 v-bind 使用
 * @returns {Function} returns.beforeChange - 单行切换流程,可单独复用
 * @returns {Map} returns.loadingMap - 行级 loading 状态(key 为 row 引用)
 */
export function useRowSwitch({
  field,
  fetch,
  activeValue = 1,
  inactiveValue = 0,
  confirm,
  beforeSubmit,
  successMsg,
  onSuccess,
  onError,
  label = field || "该字段",
  nilMsg,
  isDisabled
} = {}) {
  if (!field || typeof fetch !== "function") {
    throw new Error("[useRowSwitch] 必须提供 field 和 fetch");
  }

  // 行级 loading:key 为 row 对象引用,reactive Map 保证模板响应式,且不污染 row 数据
  const loadingMap = reactive(new Map());

  // 允许 confirm / beforeSubmit 返回 Promise 或布尔值
  const resolveBool = (value) =>
    value && typeof value.then === "function" ? promise2bool(value) : Boolean(value);

  /**
   * 单行切换完整流程:防连点 → 字段守卫 → 前置校验 → 确认 → 请求 → 成功才回写数据
   * 永远返回 false,开关翻转完全由 modelValue(row[field])驱动,组件不自翻
   */
  const beforeChange = async (row) => {
    if (loadingMap.get(row)) return false;

    const oldValue = get(row, field);

    // 守卫:字段未返回(undefined,属性缺失)时禁止发起修改请求;
    // null 是后端明确返回的"未设置"业务值,合法,允许修改(如从未设置 → 启用,界面上可以加上未设置提示)
    if (oldValue === undefined) {
      ElMessage.error(nilMsg || `后端未返回「${label}」字段,无法修改!`);
      return false;
    }

    const newValue = oldValue === activeValue ? inactiveValue : activeValue;

    if (beforeSubmit && !(await resolveBool(beforeSubmit(row, newValue, oldValue)))) {
      return false;
    }

    if (confirm && !(await resolveBool(confirm(row, newValue, oldValue)))) {
      return false;
    }

    loadingMap.set(row, true);
    try {
      const res = await fetch({ row, newValue, oldValue });
      // 只有成功才更新当前行当前字段
      set(row, field, newValue);
      if (successMsg) {
        ElMessage.success(
          typeof successMsg === "function" ? successMsg(row, newValue) : successMsg
        );
      }
      onSuccess?.(row, newValue, oldValue, res);
    } catch (e) {
      // 失败时数据未改,UI 自动保持旧值;错误提示一般由 request 拦截器统一处理
      console.error("[useRowSwitch] 修改失败:", e);
      onError?.(e, row);
    } finally {
      loadingMap.set(row, false);
    }
    return false;
  };

  /**
   * 生成 el-switch 的受控 props,直接 v-bind 到 el-switch 上
   * @param {Object} row - 当前行数据
   */
  const getSwitchProps = (row) => ({
    modelValue: get(row, field),
    activeValue,
    inactiveValue,
    loading: loadingMap.get(row) ?? false,
    disabled: isDisabled ? Boolean(isDisabled(row)) : false,
    beforeChange: () => beforeChange(row)
  });

  return {
    getSwitchProps,
    beforeChange,
    loadingMap
  };
}

promise2bool

export const promise2bool = (p) => {
  return p.then(() => true).catch(() => false);
};

下拉框 (el-select)

下拉框不会因为后端错误而请求更新接口,但是需要前端判断该不该请求更新接口,如果后端没有返回,数据不全,本就不应该触发!

SelectRowPropChange.vue

<script setup>
import DictTag from "@/components/DictTag/index.vue";
import { promise2bool } from "@/utils/async.js";
import { ElMessage, ElMessageBox } from "element-plus";
import { get, set } from "lodash-es";

const props = defineProps({
  dict: {
    type: Object
  },
  propName: {
    type: String
  },
  submitFn: {
    type: Function,
    default: (_nv, _row) => {}
  },
  propCnName: {
    type: String
  },
  rowData: {
    type: Object
  },
  disabled: {
    type: Boolean,
    default: false
  }
});

const isLoading = ref(false);

const handleSelectChange = async ({ nv }) => {
  const { propCnName, submitFn, propName, rowData } = props;

  const oldValue = get(rowData, propName);

  // 守卫:字段未返回(undefined,属性缺失)时禁止发起修改请求;
  // null 是后端明确返回的"未设置"业务值,合法,允许修改
  if (oldValue === undefined) {
    ElMessage.error(`后端未返回「${propCnName || propName}」字段,无法修改!`);
    return;
  }

  isLoading.value = true;

  const oldLabel =
    unref(props.dict).find((v) => +v.value === oldValue)?.label ??
    (oldValue === null ? "未设置" : "");
  const newLabel =
    unref(props.dict).find((v) => v.value?.toString() === nv?.toString())?.label ?? "";

  const isConfirm = await promise2bool(
    ElMessageBox.confirm(`确定要将${propCnName}从 ${oldLabel} 修改到 ${newLabel} 吗?`, "提示", {
      confirmButtonText: "确定",
      cancelButtonText: "取消",
      type: "warning"
    })
  );

  try {
    if (isConfirm) {
      await submitFn(nv, rowData);
      // 只有成功才回写行数据;调用方自行回写/刷新列表时双写同值无副作用
      set(rowData, propName, nv);
      ElMessage.success(`成功修改${propCnName}到 ${newLabel}`);
    }
  } catch (e) {
    console.log(e);
  } finally {
    isLoading.value = false;
  }
};
</script>

<template>
  <el-select
    :loading="isLoading"
    @change="(nv) => handleSelectChange({ nv })"
    :model-value="get(rowData, propName)"
    class="w-32"
    :disabled="disabled"
    @click.stop
  >
    <el-option v-for="item in dict" :key="item.value" :label="item.label" :value="+item.value">
      <dict-tag :options="dict" :value="+item.value" />
    </el-option>
  </el-select>
</template>

总结

悲观更新(Pessimistic Update) ,配套的概念是:受控组件(Controlled Component) + 服务端状态作为唯一数据源(Single Source of Truth) 。

image.png