[CMS-REWORK] #4 保存恒被拦(模板实参ref被解包致validate恒false): refs改脚本侧取用+validate返回首错精确文案
build / build (push) Successful in 2m40s

This commit is contained in:
chenxing
2026-09-29 11:03:47 +08:00
parent 01d91ddd4b
commit fe75784d13
4 changed files with 164 additions and 13 deletions
+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();
});
});
+2 -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"),
}), }),
})); }));
+19 -7
View File
@@ -322,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() {
@@ -565,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>
@@ -581,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>
@@ -597,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>
+29 -5
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: {},
}); });
@@ -71,9 +73,31 @@ function emitUp() {
emit("update:modelValue", 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 || "存在填写不合法的字段,请检查表单中标红项";
} }
/** /**
@@ -112,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>