[CMS-REWORK] #4 保存恒被拦(模板实参ref被解包致validate恒false): refs改脚本侧取用+validate返回首错精确文案
build / build (push) Successful in 2m40s
build / build (push) Successful in 2m40s
This commit is contained in:
@@ -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", () => ({
|
||||
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"),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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());
|
||||
<SiteConfigForm ref="displayFormRef" v-model="displayModel" :groups="['display']" :site-id="basic.id" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -581,7 +593,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
<SiteConfigForm ref="footerFormRef" v-model="footerModel" :groups="['footer']" :site-id="basic.id" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -597,7 +609,7 @@ watch(() => store.currentSchool?.schId, () => loadSites());
|
||||
<SiteConfigForm ref="seoFormRef" v-model="seoModel" :groups="['seo', 'domain']" :site-id="basic.id" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from "vue";
|
||||
import { nextTick, reactive, ref, watch } from "vue";
|
||||
import {
|
||||
SITE_CONFIG_FIELD_DEFS,
|
||||
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 formRef = ref();
|
||||
/** 组件根元素(校验失败时定位滚动用) */
|
||||
const rootEl = ref<HTMLElement | null>(null);
|
||||
const formModel = reactive<{ fields: Record<string, string> }>({
|
||||
fields: {},
|
||||
});
|
||||
@@ -71,9 +73,31 @@ function emitUp() {
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
/** 保存前校验:内置字段 Form 规则;通过返回 true */
|
||||
async function validate(): Promise<boolean> {
|
||||
return !!(await formRef.value?.validate());
|
||||
/**
|
||||
* 保存前校验(docs/20 返工 #4,契约由 boolean 改为 string | null):
|
||||
* 返回 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>
|
||||
|
||||
<template>
|
||||
<div class="site-config-form">
|
||||
<div ref="rootEl" class="site-config-form">
|
||||
<Form ref="formRef" :model="formModel" :label-width="130">
|
||||
<template v-for="g in visibleGroups" :key="g.name">
|
||||
<div class="group-title">{{ g.title }}</div>
|
||||
|
||||
Reference in New Issue
Block a user