[CMS-REWORK] #2 Tab假脏: seeding窗口覆盖异步flush的watcher + 子组件emitUp no-op守卫

This commit is contained in:
chenxing
2026-09-29 10:53:03 +08:00
parent 4208f8ca89
commit 39dfc645ff
4 changed files with 56 additions and 7 deletions
+30
View File
@@ -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);
});
});
+8 -3
View File
@@ -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;
+9 -2
View File
@@ -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();
}