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