[CMS-REWORK] #2 Tab假脏: seeding窗口覆盖异步flush的watcher + 子组件emitUp no-op守卫
This commit is contained in:
@@ -297,3 +297,33 @@ describe("SiteSetting 编辑模式(site/setting/:id,docs/20 F1 7 Tab)", ()
|
||||
expect(config.home_layout).toBeTruthy(); // 其他 Tab 键不受影响
|
||||
});
|
||||
});
|
||||
|
||||
describe("SiteSetting 假脏防护(docs/20 返工 #2)", () => {
|
||||
it("冷加载不标脏:7 个 Tab 标题均无 ●(种值触发的 watcher 落在 seeding 窗口内)", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
|
||||
const labels = Array.from(el.querySelectorAll(".ivu-tabs-tab")).map(t => t.textContent!.trim());
|
||||
expect(labels.length).toBe(7);
|
||||
labels.forEach(t => expect(t.includes("●")).toBe(false));
|
||||
});
|
||||
|
||||
it("真实编辑才标脏:改站点名称 → 基本信息 ●;保存成功后 ● 消失", async () => {
|
||||
routeMock.params = { id: "1" };
|
||||
const el = mountPage();
|
||||
await flush();
|
||||
const labels = () => Array.from(el.querySelectorAll(".ivu-tabs-tab")).map(t => t.textContent!.trim());
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="如 XX学校门户网站"]')!;
|
||||
setInputValue(nameInput, "改名后的门户");
|
||||
await flush();
|
||||
expect(labels().find(t => t.includes("基本信息"))!.includes("●")).toBe(true);
|
||||
|
||||
const saveBtn = [...el.querySelectorAll("button")].find(b => b.textContent?.includes("保存基本信息"))!;
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(mocks.siteSave).toHaveBeenCalledTimes(1);
|
||||
expect(labels().find(t => t.includes("基本信息"))!.includes("●")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { siteApi, themeApi } from "@/api/module/cms";
|
||||
@@ -159,7 +159,7 @@ async function loadThemes() {
|
||||
}
|
||||
|
||||
/** 载入站点 → 各 Tab 模型按 lastSavedConfigJson 种子(seeding 挂起 dirty 标记) */
|
||||
function initFromRow(id: number) {
|
||||
async function initFromRow(id: number) {
|
||||
const r = sites.value.find(s => s.id === id);
|
||||
if (!r) {
|
||||
loadError.value = "站点不存在或无权管理该站点";
|
||||
@@ -187,6 +187,10 @@ function initFromRow(id: number) {
|
||||
seoModel.value = lastSavedConfigJson.value;
|
||||
customCss.value = readCustomCss(lastSavedConfigJson.value);
|
||||
Object.keys(dirty).forEach(k => (dirty[k] = false));
|
||||
// docs/20 返工 #2:seeding 是同步布尔位,而 Vue watcher 异步 flush——种值触发的 watcher 回调
|
||||
// 落在下一个 flush 周期(此时同步代码早已把 seeding 置回 false),必须等它们在挂起窗口内
|
||||
// 跑完再放行,否则冷加载即假脏
|
||||
await nextTick();
|
||||
seeding = false;
|
||||
}
|
||||
|
||||
@@ -268,7 +272,7 @@ async function persistTab(tab: TabKey, patch: Record<string, unknown> | null) {
|
||||
}
|
||||
}
|
||||
dirty[tab] = false;
|
||||
// 保存回灌:本 Tab 模型与服务端事实源对齐(seeding 挂起 dirty 重标)
|
||||
// 保存回灌:本 Tab 模型与服务端事实源对齐(seeding 窗口须覆盖异步 flush 的 watcher,返工 #2 同 initFromRow)
|
||||
seeding = true;
|
||||
if (tab === "layout") {
|
||||
layoutModel.value = lastSavedConfigJson.value;
|
||||
@@ -276,6 +280,7 @@ async function persistTab(tab: TabKey, patch: Record<string, unknown> | null) {
|
||||
} else if (tab === "css") {
|
||||
customCss.value = readCustomCss(lastSavedConfigJson.value);
|
||||
}
|
||||
await nextTick();
|
||||
seeding = false;
|
||||
} finally {
|
||||
saving[tab] = false;
|
||||
|
||||
@@ -57,11 +57,18 @@ watch(
|
||||
);
|
||||
|
||||
function emitUp() {
|
||||
lastEmitted = serializeSiteConfig({
|
||||
const next = serializeSiteConfig({
|
||||
fields: { ...formModel.fields },
|
||||
preserved,
|
||||
});
|
||||
emit("update:modelValue", lastEmitted);
|
||||
// docs/20 返工 #2 no-op 守卫:本次序列化结果与当前传入 modelValue 经同一解析/序列化管线归一后
|
||||
// 语义等价则不回写——挂载规范化、编辑后撤销往返不再触发父级假脏(保存路径读 ownFields() 取
|
||||
// 实时表单模型,此处跳发不会造成取值滞后)。
|
||||
if (next === serializeSiteConfig(parseSiteConfig(props.modelValue))) {
|
||||
return;
|
||||
}
|
||||
lastEmitted = next;
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
/** 保存前校验:内置字段 Form 规则;通过返回 true */
|
||||
|
||||
@@ -74,8 +74,15 @@ function emitUp() {
|
||||
: current
|
||||
? { version: HOME_LAYOUT_VERSION, blocks: [] }
|
||||
: null;
|
||||
lastEmitted = serializeHomeLayout(props.modelValue, layout);
|
||||
emit("update:modelValue", lastEmitted);
|
||||
const next = serializeHomeLayout(props.modelValue, layout);
|
||||
// docs/20 返工 #2 no-op 守卫:与当前 modelValue 严格相等则不回写(等价操作往返后父级不重标脏)。
|
||||
// 此处必须用严格相等而非语义比较:保存路径 layoutPatchOf 读的是父级 layoutModel,跳发一个
|
||||
// 「语义等价但字符串不同」的值会让父级停留在旧序列化形态,保存时落库旧状态。
|
||||
if (next === props.modelValue) {
|
||||
return;
|
||||
}
|
||||
lastEmitted = next;
|
||||
emit("update:modelValue", next);
|
||||
schedulePreview();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user