From fe75784d135aeb25579792f1778930400971548e Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 29 Sep 2026 11:03:20 +0800 Subject: [PATCH] =?UTF-8?q?[CMS-REWORK]=20#4=20=E4=BF=9D=E5=AD=98=E6=81=92?= =?UTF-8?q?=E8=A2=AB=E6=8B=A6(=E6=A8=A1=E6=9D=BF=E5=AE=9E=E5=8F=82ref?= =?UTF-8?q?=E8=A2=AB=E8=A7=A3=E5=8C=85=E8=87=B4validate=E6=81=92false):=20?= =?UTF-8?q?refs=E6=94=B9=E8=84=9A=E6=9C=AC=E4=BE=A7=E5=8F=96=E7=94=A8+vali?= =?UTF-8?q?date=E8=BF=94=E5=9B=9E=E9=A6=96=E9=94=99=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/siteConfigForm.spec.ts | 114 ++++++++++++++++++++ src/__tests__/siteSetting.spec.ts | 3 +- src/views/cms/SiteSetting.vue | 26 +++-- src/views/cms/components/SiteConfigForm.vue | 34 +++++- 4 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/siteConfigForm.spec.ts diff --git a/src/__tests__/siteConfigForm.spec.ts b/src/__tests__/siteConfigForm.spec.ts new file mode 100644 index 0000000..e1eea52 --- /dev/null +++ b/src/__tests__/siteConfigForm.spec.ts @@ -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(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("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(); + }); +}); diff --git a/src/__tests__/siteSetting.spec.ts b/src/__tests__/siteSetting.spec.ts index 4fec090..a2a1f0d 100644 --- a/src/__tests__/siteSetting.spec.ts +++ b/src/__tests__/siteSetting.spec.ts @@ -70,7 +70,8 @@ vi.mock("@/components/theme/ThemePreviewFrame.vue", () => ({ vi.mock("@/views/cms/components/SiteConfigForm.vue", () => ({ default: defineComponent({ props: ["modelValue", "groups", "siteId"], - methods: { validate: async () => true, ownFields: () => ({}) }, + // 返工 #4 起 validate 契约:null=通过;失败返回首个错误文案 + methods: { validate: async () => null, ownFields: () => ({}) }, render: () => h("div", "config-form-stub"), }), })); diff --git a/src/views/cms/SiteSetting.vue b/src/views/cms/SiteSetting.vue index 227236f..485fa6c 100644 --- a/src/views/cms/SiteSetting.vue +++ b/src/views/cms/SiteSetting.vue @@ -322,12 +322,24 @@ async function saveLayout() { await persistTab("layout", layoutPatchOf(layoutModel.value)); } -async function saveGroupForm(tab: "display" | "footer" | "seo", formRef: typeof displayFormRef) { - if (!(await formRef.value?.validate())) { - Message.warning("请填写红色星号内容!"); // bsch §4 +/** 分区表单 ref 表(docs/20 返工 #4:模板实参里的 ref 会被 Vue 自动解包成组件实例, + * 旧代码按 `formRef.value?.validate()` 调用恒为 undefined → validate 恒 false;refs 必须在脚本侧取用) */ +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; } - await persistTab(tab, formRef.value?.ownFields?.() || {}); + await persistTab(tab, form?.ownFields?.() || {}); } async function saveCss() { @@ -565,7 +577,7 @@ watch(() => store.currentSchool?.schId, () => loadSites()); @@ -581,7 +593,7 @@ watch(() => store.currentSchool?.schId, () => loadSites()); @@ -597,7 +609,7 @@ watch(() => store.currentSchool?.schId, () => loadSites()); diff --git a/src/views/cms/components/SiteConfigForm.vue b/src/views/cms/components/SiteConfigForm.vue index e91318f..712d92e 100644 --- a/src/views/cms/components/SiteConfigForm.vue +++ b/src/views/cms/components/SiteConfigForm.vue @@ -1,5 +1,5 @@