行内编辑,如何防止后端未返回,直接全部更新了?

情形

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

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

排查后发现,问题可能出在前后端字段不一致上:后端接口没有返回某个字段,但前端的 el-switch 仍然绑定了这个字段。由于字段值为 undefined,前端的默认值处理或相关编辑逻辑可能会将其转换成 0、1 等有效值,进而触发更新请求。

但实际上,这个字段后端根本没有提供,也可能没有对应的更新逻辑,最终导致请求失败。

行内编辑不能只考虑字段有值时如何修改,还需要考虑字段缺失时如何处理。尤其要避免将缺失字段的默认值误认为真实业务值,更不能在用户没有操作的情况下触发更新请求。

解决

js 复制代码
import { reactive } from "vue";
import { ElMessage } from "element-plus";
import { get, isNil, 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);

    // 守卫:后端没返回该字段时禁止发起修改请求
    if (isNil(oldValue)) {
      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
  };
}

下拉框可能也会导致

js 复制代码
<script setup>
import DictTag from "@/components/DictTag/index.vue";
import { promise2bool } from "@/utils/async.js";
import { ElMessage, ElMessageBox } from "element-plus";
import { get, isNil, 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);

  // 守卫:后端没返回该字段时禁止发起修改请求
  if (isNil(oldValue)) {
    ElMessage.error(`后端未返回「${propCnName || propName}」字段,无法修改!`);
    return;
  }

  isLoading.value = true;

  const oldLabel = unref(props.dict).find((v) => +v.value === oldValue)?.label ?? "";
  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>

promise2bool

js 复制代码
export const promise2bool = (p) => {
  return p.then(() => true).catch(() => false);
};

总结

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

相关推荐
零售校长1 小时前
二七区零售门店店长面试,重点在哪几处
面试·职场和发展·零售
考研保研资料分享1 小时前
农学跨保清华基础医学直博:四方联合与四校考核复盘(2024)
面试
wjjzhbb1 小时前
前端监控:给用户体验装上仪表盘
前端·ux
江湖有缘2 小时前
Docker实战 | Docker部署Minimalist-web-notepad个人笔记本
前端·docker·notepad++
Rain的Java大神之路2 小时前
百万并发直播弹幕系统设计全解:看懂了扇出,你就看懂了高并发的八成
java·后端·面试
Hilaku2 小时前
为什么我在团队里禁止使用 axios?
前端·javascript·程序员
兮动人2 小时前
Chrome 152 升级后本地请求全挂了?是 LNA 策略在搞事
前端·chrome·lna·local network
Thomas.Sir2 小时前
第35课:Spring Cloud 2025架构复盘、性能调优 & 面试核心总结
spring cloud·面试·架构
JarvanMo2 小时前
让 3D 在 Flutter 中焕发生机:Flutter GPU 与 flutter_scene 实战经验
前端