Compare commits

...
6 Commits
7 changed files with 352 additions and 29 deletions
+6 -1
View File
@@ -19,7 +19,10 @@ jobs:
options: --network infra-infra-net options: --network infra-infra-net
steps: steps:
- name: 安装 git / curl / zip(alpine) - name: 安装 git / curl / zip(alpine)
run: apk add --no-cache git curl zip run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl zip
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库 # 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库) - name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -51,6 +54,8 @@ jobs:
set -e set -e
cd /build cd /build
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
npm config set registry https://registry.npmmirror.com
npm ci npm ci
npm run build npm run build
' '
+75 -2
View File
@@ -49,7 +49,10 @@ const TREE: CategoryTreeNode[] = [
{ id: 12, name: "媒体聚焦", type: 1, children: [] }, { id: 12, name: "媒体聚焦", type: 1, children: [] },
] as unknown as CategoryTreeNode[]; ] as unknown as CategoryTreeNode[];
async function mountEditor(configJson: string, opts?: { siteId?: number; portalUrl?: string }) { async function mountEditor(
configJson: string,
opts?: { siteId?: number; portalUrl?: string; siteIdRef?: ReturnType<typeof ref<number | undefined>> },
) {
const el = document.createElement("div"); const el = document.createElement("div");
document.body.appendChild(el); document.body.appendChild(el);
const model = ref(configJson); const model = ref(configJson);
@@ -58,7 +61,7 @@ async function mountEditor(configJson: string, opts?: { siteId?: number; portalU
render: () => render: () =>
h(HomeLayoutEditor, { h(HomeLayoutEditor, {
modelValue: model.value, modelValue: model.value,
siteId: opts?.siteId, siteId: opts?.siteIdRef ? opts.siteIdRef.value : opts?.siteId,
portalUrl: opts?.portalUrl, portalUrl: opts?.portalUrl,
"onUpdate:modelValue": (v: string) => (model.value = v), "onUpdate:modelValue": (v: string) => (model.value = v),
}), }),
@@ -237,6 +240,76 @@ describe("HomeLayoutEditor - F2 三栏 + 预览 + 缩略图 + 自动编排(doc
}); });
}); });
describe("HomeLayoutEditor - 栏目树加载与首用编排解耦(docs/20 返工 #1)", () => {
it("已有布局 + 有列表栏目 + siteId:栏目树仍无条件加载,数据栏目可勾选", async () => {
treeMock.send.mockResolvedValue(TREE);
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 }] },
});
const { el, model } = await mountEditor(config, { siteId: 1 });
// 树加载发生(幂等标记下 treeMock 恰好 1 次调用)
expect(treeMock.send).toHaveBeenCalledTimes(1);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const cats = fieldOf(el as HTMLElement, "数据栏目")!;
expect(cats).not.toBeNull();
expect(cats.querySelectorAll(".hle-cat").length).toBe(2);
expect(cats.textContent).toContain("校园时讯");
expect(cats.textContent).toContain("媒体聚焦");
// 已有布局不被自动编排改写
expect(JSON.parse(model.value).home_layout.blocks[0].id).toBe("a1");
});
it("siteId 晚于挂载到位(父级异步种入):watch 补跑加载,数据栏目可用", async () => {
treeMock.send.mockResolvedValue(TREE);
const siteIdRef = ref<number | undefined>(undefined);
const config = JSON.stringify({
home_layout: { version: 1, blocks: [{ id: "a1", type: "article_list", categoryIds: [], style: "card", size: 8 }] },
});
const { el } = await mountEditor(config, { siteIdRef });
await settle();
// siteId 未到位时不请求
expect(treeMock.send).not.toHaveBeenCalled();
siteIdRef.value = 1;
await settle(6);
expect(treeMock.send).toHaveBeenCalledTimes(1);
(el.querySelector(".hle-row") as HTMLElement).click();
await settle();
const cats = fieldOf(el as HTMLElement, "数据栏目")!;
expect(cats.querySelectorAll(".hle-cat").length).toBe(2);
});
it("watch + onMounted 双触发下幂等:同 siteId 只请求一次", async () => {
treeMock.send.mockResolvedValue(TREE);
const siteIdRef = ref<number | undefined>(1);
await mountEditor(JSON.stringify({ logo: "/a.png" }), { siteIdRef });
await settle(6);
expect(treeMock.send).toHaveBeenCalledTimes(1);
});
});
describe("HomeLayoutEditor - 区块序列序号徽标(docs/20 返工 #3)", () => {
it("每行右上角显示连续序号(1 起始、含隐藏区块不重排),与校验报错「第 N 个区块」对齐", async () => {
const config = JSON.stringify({
home_layout: {
version: 1,
blocks: [
{ id: "a1", type: "article_list", categoryIds: [11], style: "card", size: 8 },
{ id: "n1", type: "notice_bar", text: "公告", visible: false },
{ id: "g1", type: "gallery" },
],
},
});
const { el } = await mountEditor(config);
const nos = Array.from(el.querySelectorAll(".hle-row-no")).map(n => n.textContent!.trim());
expect(nos).toEqual(["1", "2", "3"]);
// 隐藏区块(第 2 行)保留序号、行整体置灰,编号不重排
const rows = Array.from(el.querySelectorAll(".hle-row"));
expect(rows[1].className).toContain("hidden");
expect(rows[1].querySelector(".hle-row-no")!.textContent!.trim()).toBe("2");
});
});
describe("HomeLayoutEditor - D 组件属性面板(docs/20 D-1/D-8)", () => { describe("HomeLayoutEditor - D 组件属性面板(docs/20 D-1/D-8)", () => {
it("stats_band:统计项三列编辑器(名称/数值/单位)渲染并可增行", async () => { it("stats_band:统计项三列编辑器(名称/数值/单位)渲染并可增行", async () => {
const config = JSON.stringify({ const config = JSON.stringify({
+114
View File
@@ -0,0 +1,114 @@
// @vitest-environment jsdom
/**
* SiteConfigForm 组件级回归(docs/20 返工 #4):
* - 本分区无必填字段:全空 → validate() 通过(返回 null),不因「必填」拦保存;
* - 全合法值 → validate() 通过;
* - 填了但格式非法 → validate() 返回该字段的明确错误(「字段名:原因」),
* 且表单内出现 iView 内联红框(.ivu-form-item-error),不再有「红星必填」式误导。
* 沿用手写 createApp 挂载 + vi.mock 桩风格(与 homeLayoutEditor.spec.ts 一致)。
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import ViewUIPlus from "view-ui-plus";
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {});
import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue";
const cleanups: (() => void)[] = [];
/** 排空微任务 + 一个宏任务(iView 校验态/DOM 类名更新走 nextTick) */
async function settle(frames = 4) {
for (let i = 0; i < frames; i++) {
await nextTick();
}
await new Promise(r => setTimeout(r, 0));
for (let i = 0; i < frames; i++) {
await nextTick();
}
}
async function mountForm(modelValue: string, groups: string[]) {
const el = document.createElement("div");
document.body.appendChild(el);
const model = ref(modelValue);
const childRef = ref<any>(null);
const app = createApp(
defineComponent({
setup() {
return () =>
h(SiteConfigForm, {
ref: childRef,
modelValue: model.value,
groups,
"onUpdate:modelValue": (v: string) => (model.value = v),
});
},
}),
);
app.use(ViewUIPlus);
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
});
await settle(6);
return { el, model, child: childRef };
}
/** 模拟真实输入:走原生 input 事件(iView Input 据此回填 v-model 并触发 on-change 回写) */
async function typeByPlaceholder(el: HTMLElement, placeholder: string, value: string) {
const input = Array.from(el.querySelectorAll<HTMLInputElement>("input, textarea")).find(
n => n.getAttribute("placeholder") === placeholder,
);
expect(input, `placeholder ${placeholder} 对应的输入框应存在`).toBeTruthy();
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input!, value);
input!.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
}
beforeEach(() => {});
afterEach(() => {
cleanups.splice(0).forEach(fn => fn());
});
describe("SiteConfigForm 校验(docs/20 返工 #4)", () => {
it("全空(无必填字段)→ validate() 通过返回 null", async () => {
const { child } = await mountForm("", ["display"]);
const err = await child.value.validate();
expect(err).toBeNull();
});
it("全合法值 → validate() 通过返回 null", async () => {
const { el, child } = await mountForm("", ["display"]);
await typeByPlaceholder(el, "门户顶栏展示的一句话标语(可选)", "云海大学欢迎您");
await typeByPlaceholder(el, "留空默认「专业院系」", "院系导航");
const err = await child.value.validate();
expect(err).toBeNull();
});
it("合法 configJson 种入 + 手填非法 URL → 返回「字段名:原因」,出现内联红框", async () => {
const { el, child } = await mountForm(JSON.stringify({ logo: "/yschcms/upload/2026/06/logo.png" }), ["footer"]);
await typeByPlaceholder(el, "微博主页链接 https://weibo.com/…(留空不展示图标)", "不是链接");
const err = await child.value.validate();
expect(typeof err).toBe("string");
expect(err).toContain("微博");
expect(err).toContain("http");
// 内联红框 + 字段下方错误文案存在
expect(el.querySelector(".ivu-form-item-error .ivu-form-item-error-tip")).toBeTruthy();
});
it("asset 字段非法值同样精确报错(种入非法 logo 值)", async () => {
// REWORK-DECISION: 经控件 UI 填 asset 值的路径依赖上传(jsdom 不可行)与「手动粘贴」通道
// (其「填入」按钮因 slot="append" 旧语法在 Vue 3 下不渲染,属本次返工范围外的存量缺陷,
// 见返工报告遗留项),故从种入 configJson 驱动:等价覆盖「存在非法 asset 值 → 精确报错」。
const { el, child } = await mountForm(JSON.stringify({ logo: "随便写的路径" }), ["display"]);
const err = await child.value.validate();
expect(typeof err).toBe("string");
expect(err).toContain("站点 Logo");
expect(err).toContain("上传路径");
expect(el.querySelector(".ivu-form-item-error")).toBeTruthy();
});
});
+32 -1
View File
@@ -70,7 +70,8 @@ vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({
vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({ vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({
default: defineComponent({ default: defineComponent({
props: ["modelValue", "groups", "siteId"], props: ["modelValue", "groups", "siteId"],
methods: { validate: async () => true, ownFields: () => ({}) }, // 返工 #4 起 validate 契约:null=通过;失败返回首个错误文案
methods: { validate: async () => null, ownFields: () => ({}) },
render: () => h("div", "config-form-stub"), render: () => h("div", "config-form-stub"),
}), }),
})); }));
@@ -297,3 +298,33 @@ describe("SiteSetting 编辑模式(site/setting/:id,docs/20 F1 7 Tab)", ()
expect(config.home_layout).toBeTruthy(); // 其他 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);
});
});
+27 -10
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { siteApi, themeApi } from "@/api/module/cms"; import { siteApi, themeApi } from "@/api/module/cms";
@@ -159,7 +159,7 @@ async function loadThemes() {
} }
/** 载入站点 → 各 Tab 模型按 lastSavedConfigJson 种子(seeding 挂起 dirty 标记) */ /** 载入站点 → 各 Tab 模型按 lastSavedConfigJson 种子(seeding 挂起 dirty 标记) */
function initFromRow(id: number) { async function initFromRow(id: number) {
const r = sites.value.find(s => s.id === id); const r = sites.value.find(s => s.id === id);
if (!r) { if (!r) {
loadError.value = "站点不存在或无权管理该站点"; loadError.value = "站点不存在或无权管理该站点";
@@ -187,6 +187,10 @@ function initFromRow(id: number) {
seoModel.value = lastSavedConfigJson.value; seoModel.value = lastSavedConfigJson.value;
customCss.value = readCustomCss(lastSavedConfigJson.value); customCss.value = readCustomCss(lastSavedConfigJson.value);
Object.keys(dirty).forEach(k => (dirty[k] = false)); Object.keys(dirty).forEach(k => (dirty[k] = false));
// docs/20 返工 #2:seeding 是同步布尔位,而 Vue watcher 异步 flush——种值触发的 watcher 回调
// 落在下一个 flush 周期(此时同步代码早已把 seeding 置回 false),必须等它们在挂起窗口内
// 跑完再放行,否则冷加载即假脏
await nextTick();
seeding = false; seeding = false;
} }
@@ -268,7 +272,7 @@ async function persistTab(tab: TabKey, patch: Record<string, unknown> | null) {
} }
} }
dirty[tab] = false; dirty[tab] = false;
// 保存回灌:本 Tab 模型与服务端事实源对齐(seeding 挂起 dirty 重标) // 保存回灌:本 Tab 模型与服务端事实源对齐(seeding 窗口须覆盖异步 flush 的 watcher,返工 #2 同 initFromRow)
seeding = true; seeding = true;
if (tab === "layout") { if (tab === "layout") {
layoutModel.value = lastSavedConfigJson.value; layoutModel.value = lastSavedConfigJson.value;
@@ -276,6 +280,7 @@ async function persistTab(tab: TabKey, patch: Record<string, unknown> | null) {
} else if (tab === "css") { } else if (tab === "css") {
customCss.value = readCustomCss(lastSavedConfigJson.value); customCss.value = readCustomCss(lastSavedConfigJson.value);
} }
await nextTick();
seeding = false; seeding = false;
} finally { } finally {
saving[tab] = false; saving[tab] = false;
@@ -317,12 +322,24 @@ async function saveLayout() {
await persistTab("layout", layoutPatchOf(layoutModel.value)); await persistTab("layout", layoutPatchOf(layoutModel.value));
} }
async function saveGroupForm(tab: "display" | "footer" | "seo", formRef: typeof displayFormRef) { /** 分区表单 ref 表(docs/20 返工 #4:模板实参里的 ref 会被 Vue 自动解包成组件实例,
if (!(await formRef.value?.validate())) { * 旧代码按 `formRef.value?.validate()` 调用恒为 undefined → validate 恒 false;refs 必须在脚本侧取用) */
Message.warning("请填写红色星号内容!"); // bsch §4 const groupFormRefs: Record<"display" | "footer" | "seo", typeof displayFormRef> = {
display: displayFormRef,
footer: footerFormRef,
seo: seoFormRef,
};
async function saveGroupForm(tab: "display" | "footer" | "seo") {
const form = groupFormRefs[tab].value;
const firstError = await form?.validate();
if (firstError) {
// 精确到「字段名:原因」(内联红框 + 字段下方文案 + 滚动定位由 SiteConfigForm.validate 承担),
// 不再用「请填写红色星号内容」这类与实际校验机制不匹配的通用提示
Message.warning(firstError);
return; return;
} }
await persistTab(tab, formRef.value?.ownFields?.() || {}); await persistTab(tab, form?.ownFields?.() || {});
} }
async function saveCss() { async function saveCss() {
@@ -560,7 +577,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<SiteConfigForm ref="displayFormRef" v-model="displayModel" :groups="['display']" :site-id="basic.id" /> <SiteConfigForm ref="displayFormRef" v-model="displayModel" :groups="['display']" :site-id="basic.id" />
</div> </div>
<div class="card-footer"> <div class="card-footer">
<Button type="primary" :loading="saving.display" @click="saveGroupForm('display', displayFormRef)">保存基础展示</Button> <Button type="primary" :loading="saving.display" @click="saveGroupForm('display')">保存基础展示</Button>
<span class="card-hint">仅保存本 Tab 的字段</span> <span class="card-hint">仅保存本 Tab 的字段</span>
</div> </div>
</div> </div>
@@ -576,7 +593,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<SiteConfigForm ref="footerFormRef" v-model="footerModel" :groups="['footer']" :site-id="basic.id" /> <SiteConfigForm ref="footerFormRef" v-model="footerModel" :groups="['footer']" :site-id="basic.id" />
</div> </div>
<div class="card-footer"> <div class="card-footer">
<Button type="primary" :loading="saving.footer" @click="saveGroupForm('footer', footerFormRef)">保存页脚信息</Button> <Button type="primary" :loading="saving.footer" @click="saveGroupForm('footer')">保存页脚信息</Button>
<span class="card-hint">仅保存本 Tab 的字段</span> <span class="card-hint">仅保存本 Tab 的字段</span>
</div> </div>
</div> </div>
@@ -592,7 +609,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
<SiteConfigForm ref="seoFormRef" v-model="seoModel" :groups="['seo', 'domain']" :site-id="basic.id" /> <SiteConfigForm ref="seoFormRef" v-model="seoModel" :groups="['seo', 'domain']" :site-id="basic.id" />
</div> </div>
<div class="card-footer"> <div class="card-footer">
<Button type="primary" :loading="saving.seo" @click="saveGroupForm('seo', seoFormRef)">保存 SEO 与域名</Button> <Button type="primary" :loading="saving.seo" @click="saveGroupForm('seo')">保存 SEO 与域名</Button>
<span class="card-hint">仅保存本 Tab 的字段</span> <span class="card-hint">仅保存本 Tab 的字段</span>
</div> </div>
</div> </div>
+38 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, watch } from "vue"; import { nextTick, reactive, ref, watch } from "vue";
import { import {
SITE_CONFIG_FIELD_DEFS, SITE_CONFIG_FIELD_DEFS,
SITE_CONFIG_GROUPS, SITE_CONFIG_GROUPS,
@@ -34,6 +34,8 @@ const groupSet = (() => new Set(props.groups || SITE_CONFIG_GROUPS.map(g => g.na
const visibleGroups = SITE_CONFIG_GROUPS.filter(g => groupSet.has(g.name)); const visibleGroups = SITE_CONFIG_GROUPS.filter(g => groupSet.has(g.name));
const formRef = ref(); const formRef = ref();
/** 组件根元素(校验失败时定位滚动用) */
const rootEl = ref<HTMLElement | null>(null);
const formModel = reactive<{ fields: Record<string, string> }>({ const formModel = reactive<{ fields: Record<string, string> }>({
fields: {}, fields: {},
}); });
@@ -57,16 +59,45 @@ watch(
); );
function emitUp() { function emitUp() {
lastEmitted = serializeSiteConfig({ const next = serializeSiteConfig({
fields: { ...formModel.fields }, fields: { ...formModel.fields },
preserved, 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 */ /**
async function validate(): Promise<boolean> { * 保存前校验(docs/20 返工 #4,契约由 boolean 改为 string | null):
return !!(await formRef.value?.validate()); * 返回 null = 通过;失败返回首个错误的原因文案(「字段名:原因」)。
* 本分区无必填字段(空值恒通过),只拦「填了但格式非法」的值;iView 内联红框 + 字段下方
* 错误文案由 Form.validate 标注,此处负责把首个出错字段滚动到可视区。
*/
async function validate(): Promise<string | null> {
const form = formRef.value;
if (!form) {
// REWORK-DECISION: Form 未挂载视为无可校验内容(全字段可选,空值恒通过),不拦保存
return null;
}
const ok = await form.validate();
if (ok) {
return null;
}
await nextTick();
const firstErrorItem = rootEl.value?.querySelector<HTMLElement>(".ivu-form-item-error") || null;
firstErrorItem?.scrollIntoView?.({ block: "center", behavior: "smooth" });
const label = firstErrorItem?.querySelector(".ivu-form-item-label")?.textContent?.trim() || "";
const tip = firstErrorItem?.querySelector(".ivu-form-item-error-tip")?.textContent?.trim() || "";
if (label && tip) {
return `${label}:${tip}`;
}
return tip || "存在填写不合法的字段,请检查表单中标红项";
} }
/** /**
@@ -105,7 +136,7 @@ function defsOf(group: string): SiteConfigFieldDef[] {
}</script> }</script>
<template> <template>
<div class="site-config-form"> <div ref="rootEl" class="site-config-form">
<Form ref="formRef" :model="formModel" :label-width="130"> <Form ref="formRef" :model="formModel" :label-width="130">
<template v-for="g in visibleGroups" :key="g.name"> <template v-for="g in visibleGroups" :key="g.name">
<div class="group-title">{{ g.title }}</div> <div class="group-title">{{ g.title }}</div>
+60 -8
View File
@@ -74,8 +74,15 @@ function emitUp() {
: current : current
? { version: HOME_LAYOUT_VERSION, blocks: [] } ? { version: HOME_LAYOUT_VERSION, blocks: [] }
: null; : null;
lastEmitted = serializeHomeLayout(props.modelValue, layout); const next = serializeHomeLayout(props.modelValue, layout);
emit("update:modelValue", lastEmitted); // docs/20 返工 #2 no-op 守卫:与当前 modelValue 严格相等则不回写(等价操作往返后父级不重标脏)。
// 此处必须用严格相等而非语义比较:保存路径 layoutPatchOf 读的是父级 layoutModel,跳发一个
// 「语义等价但字符串不同」的值会让父级停留在旧序列化形态,保存时落库旧状态。
if (next === props.modelValue) {
return;
}
lastEmitted = next;
emit("update:modelValue", next);
schedulePreview(); schedulePreview();
} }
@@ -92,24 +99,36 @@ watch(
{ immediate: true }, { immediate: true },
); );
/** 已完成树加载的站点 id(幂等标记;null=未加载或上次失败可重试) */
let loadedTreeSiteId: number | null = null;
async function loadTree() { async function loadTree() {
if (!props.siteId) return; if (!props.siteId) return;
// REWORK-DECISION: 幂等保护(返工清单 #1-3)——同一 siteId 已加载过不重复请求,避免 onMounted + watch 双触发;
// 请求失败则清标记允许后续重试
if (loadedTreeSiteId === props.siteId) return;
loadedTreeSiteId = props.siteId;
treeLoading.value = true; treeLoading.value = true;
try { try {
tree.value = (await categoryApi.tree(props.siteId).send()) || []; tree.value = (await categoryApi.tree(props.siteId).send()) || [];
} catch { } catch {
tree.value = []; tree.value = [];
loadedTreeSiteId = null;
} finally { } finally {
treeLoading.value = false; treeLoading.value = false;
} }
} }
/** F2 首用自动编排:进入编辑器时无 home_layout 键(回落态)且站点有列表型栏目 → 静默生成初始序列 */ /**
async function autoGenOnFirstUse() { * 站点上下文就绪后的数据初始化(docs/20 返工 #1):
if (parseHomeLayout(props.modelValue) || !props.siteId) { * ① 只要 siteId 有值就无条件加载栏目树(「数据栏目/人物分组」数据源),与「是否首用自动编排」解耦;
return; * ② 首用自动编排(D-4)复用已加载的树,不再独立承担加载职责。
} * siteId 由父级异步种入(晚于本组件挂载)时由 watch 补跑。
*/
async function initSiteData() {
if (!props.siteId) return;
await loadTree(); await loadTree();
if (parseHomeLayout(props.modelValue)) return;
if (!blocks.value.length && flat.value.length) { if (!blocks.value.length && flat.value.length) {
blocks.value = genFromCategories(tree.value, props.modelValue).blocks; blocks.value = genFromCategories(tree.value, props.modelValue).blocks;
selectedId.value = null; selectedId.value = null;
@@ -117,7 +136,13 @@ async function autoGenOnFirstUse() {
} }
} }
onMounted(autoGenOnFirstUse); onMounted(initSiteData);
watch(
() => props.siteId,
id => {
if (id) initSiteData();
},
);
// ===== 中栏:实时预览(iframe + 视口切换 + postMessage 近似模拟 + 手动刷新兜底)===== // ===== 中栏:实时预览(iframe + 视口切换 + postMessage 近似模拟 + 手动刷新兜底)=====
@@ -360,6 +385,9 @@ defineExpose({ validate, refreshAfterSave });
:class="{ active: b.id === selectedId, hidden: !b.visible }" :class="{ active: b.id === selectedId, hidden: !b.visible }"
@click="selectedId = b.id" @click="selectedId = b.id"
> >
<!-- 返工 #3:序号徽标按数组位置编号(含隐藏区块,不重排),与 validateHomeLayout
的「第 ${i+1} 个区块」报错口径一一对应 -->
<span class="hle-row-no">{{ i + 1 }}</span>
<span class="hle-row-vis" :class="`vis-${b.type}${b.type === 'article_list' ? '-' + (b.style || 'card') : ''}`"></span> <span class="hle-row-vis" :class="`vis-${b.type}${b.type === 'article_list' ? '-' + (b.style || 'card') : ''}`"></span>
<span class="hle-row-main"> <span class="hle-row-main">
<Icon :type="blockDefOf(b.type)?.icon || 'md-square'" class="hle-row-icon" /> <Icon :type="blockDefOf(b.type)?.icon || 'md-square'" class="hle-row-icon" />
@@ -631,6 +659,8 @@ defineExpose({ validate, refreshAfterSave });
gap: 8px; gap: 8px;
max-height: 640px; max-height: 640px;
overflow: auto; overflow: auto;
// 首行序号徽标悬浮在行框上沿(-7px),留出顶部空间防裁剪
padding-top: 8px;
} }
.hle-row { .hle-row {
position: relative; position: relative;
@@ -646,6 +676,28 @@ defineExpose({ validate, refreshAfterSave });
&.hidden { &.hidden {
opacity: 0.55; opacity: 0.55;
} }
// 返工 #3:右上角序号徽标(悬浮于行框上沿,不与 ops 按钮带重叠)
.hle-row-no {
position: absolute;
top: -7px;
right: 6px;
min-width: 16px;
height: 15px;
line-height: 13px;
padding: 0 4px;
border-radius: 8px;
background: #e8f3ff;
border: 1px solid #9cc7f5;
color: #2d8cf0;
font-size: 11px;
font-weight: 600;
text-align: center;
}
&.hidden .hle-row-no {
background: #f5f5f5;
border-color: #dcdfe6;
color: #a8abb2;
}
.hle-row-vis { .hle-row-vis {
position: absolute; position: absolute;
left: 5px; left: 5px;