Compare commits
6
Commits
cff4325947
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9719009b3 | ||
|
|
02bb3ac96a | ||
|
|
fe75784d13 | ||
|
|
01d91ddd4b | ||
|
|
39dfc645ff | ||
|
|
4208f8ca89 |
@@ -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
|
||||||
'
|
'
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user