情形
不知道大家有没有遇到过这种情况:
给 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) 。