From 6334b24d34541f265144ff8266d52fc2b7f09684 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 28 Sep 2026 14:59:05 +0800 Subject: [PATCH] =?UTF-8?q?[CMS-VISUAL]=20F5:=20=E9=A1=B5=E8=84=9A?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E7=A4=BA=E6=84=8F=E5=9B=BE=EF=BC=88=E5=85=AD?= =?UTF-8?q?=E5=A4=84=E4=BD=8D=E7=BD=AE=E6=A0=87=E6=B3=A8+=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E5=9B=9E=E6=98=BE=EF=BC=89+=20favicon=201:1/=E4=BA=8C?= =?UTF-8?q?=E7=BB=B4=E7=A0=81=204:3=20=E5=8D=A0=E4=BD=8D=E6=A1=86=E4=B8=8E?= =?UTF-8?q?=E6=AF=94=E4=BE=8B=E9=A2=84=E8=A7=88=EF=BC=88180=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=BB=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/siteVisualAid.spec.ts | 107 ++++++++++++ src/components/FooterPreview.vue | 171 ++++++++++++++++++++ src/components/ImageAssetField.vue | 37 ++++- src/utils/siteConfigForm.ts | 8 +- src/views/cms/components/SiteConfigForm.vue | 18 +++ 5 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/siteVisualAid.spec.ts create mode 100644 src/components/FooterPreview.vue diff --git a/src/__tests__/siteVisualAid.spec.ts b/src/__tests__/siteVisualAid.spec.ts new file mode 100644 index 0000000..27c6cf3 --- /dev/null +++ b/src/__tests__/siteVisualAid.spec.ts @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +/** + * F5(docs/20)页脚/基础展示视觉位置示意: + * ① 页脚分组底部渲染 FooterPreview 结构示意图(六处位置标注,已填内容回显); + * ② asset 字段的 aspect 占位框(favicon 1:1 / 二维码 4:3,无值虚线框、有值比例预览)。 + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp, defineComponent, h, nextTick, ref } from "vue"; +import ViewUIPlus from "view-ui-plus"; + +vi.mock("@/api/module/cms", () => ({ + assetUrl: (u: string) => u, + uploadUrl: () => "/api/attachment/upload", +})); + +import SiteConfigForm from "@/views/cms/components/SiteConfigForm.vue"; + +const cleanups: (() => void)[] = []; +async function settle(frames = 4) { + for (let i = 0; i < frames; i++) { + await nextTick(); + } + await new Promise(r => setTimeout(r, 0)); + await nextTick(); +} + +const FOOTER_CONFIG = JSON.stringify({ + contact: "电话 0571-88888888", + footer_addresses: "潭山校区:南湖路 237 号\n淮河校区:大学路 1 号", + qr_wechat: "/cms/upload/qr-wechat.png", + social_weibo: "https://weibo.com/xxx", + copyright: "XX学校 · 版权所有 © 2026", + beian: "浙ICP备XXXXXXXX号-1", + logo: "/cms/upload/logo.png", +}); + +async function mountForm(configJson: string, groups?: string[]) { + const el = document.createElement("div"); + document.body.appendChild(el); + const model = ref(configJson); + const app = createApp( + defineComponent({ + render: () => + h(SiteConfigForm, { + 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(); + return el; +} + +afterEach(() => { + cleanups.splice(0).forEach(fn => fn()); +}); + +describe("F5 视觉位置示意(docs/20)", () => { + it("页脚分组:底部渲染结构示意图,六处位置标注 + 已填内容回显", async () => { + const el = await mountForm(FOOTER_CONFIG, ["footer"]); + const preview = el.querySelector(".footer-preview")!; + expect(preview).toBeTruthy(); + const text = preview.textContent || ""; + // 已填字段实时回显内容(而非占位标注) + expect(text).toContain("电话 0571-88888888"); + expect(text).toContain("潭山校区:南湖路 237 号"); + expect(text).toContain("XX学校 · 版权所有 © 2026"); + expect(text).toContain("浙ICP备XXXXXXXX号-1"); + expect(preview.querySelector('img[src="/cms/upload/qr-wechat.png"]')).toBeTruthy(); + // 未填字段显示位置标注(微博二维码空 → 占位) + expect(text).toContain("微博二维码"); + expect(text).toContain("社媒图标行"); + }); + + it("页脚全空配置:六处位置标注完整(① 联系方式/② 地址/③ 二维码/④ 社媒/⑤ 版权/⑥ 备案)", async () => { + const el = await mountForm("{}", ["footer"]); + const text = el.querySelector(".footer-preview")!.textContent || ""; + expect(text).toContain("① 联系方式"); + expect(text).toContain("② 多校区地址"); + expect(text).toContain("微信二维码"); + expect(text).toContain("③ 4:3"); + expect(text).toContain("④ 社媒图标行"); + expect(text).toContain("⑤ 版权文字"); + expect(text).toContain("⑥ 备案号"); + }); + + it("基础展示分组:无示意图(仅页脚有)", async () => { + const el = await mountForm(FOOTER_CONFIG, ["display"]); + expect(el.querySelector(".footer-preview")).toBeFalsy(); + }); + + it("aspect 占位框:二维码无值时 4:3 虚线占位、favicon 无值时 1:1 占位", async () => { + const el = await mountForm("{}", ["display", "footer"]); + const holders = el.querySelectorAll(".iaf-holder"); + expect(holders.length).toBe(3); // favicon 1:1 + 微信/微博 4:3 + expect(el.querySelector(".iaf-holder-1x1")).toBeTruthy(); + expect(el.querySelectorAll(".iaf-holder-4x3").length).toBe(2); + expect(holders[0].textContent).toContain("1:1"); + }); +}); diff --git a/src/components/FooterPreview.vue b/src/components/FooterPreview.vue new file mode 100644 index 0000000..ab2a752 --- /dev/null +++ b/src/components/FooterPreview.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/src/components/ImageAssetField.vue b/src/components/ImageAssetField.vue index 4ec27ad..d998ad6 100644 --- a/src/components/ImageAssetField.vue +++ b/src/components/ImageAssetField.vue @@ -15,6 +15,9 @@ const props = defineProps<{ /** 附件场景(空=不带 scene;svc 场景已迁办事大厅,CMS 内为 article/banner 等) */ scene?: string; placeholder?: string; + /** F5(docs/20):预览/占位框比例("1:1" favicon / "4:3" 二维码;缺省 8:5 通用横图), + * 无值时按比例显示虚线占位框,帮助确认最终在门户中的尺寸感 */ + aspect?: "1:1" | "4:3"; }>(); const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>(); @@ -76,12 +79,16 @@ function clearValue() { > - + + + + {{ aspect }} 位 + @@ -123,6 +130,16 @@ function clearValue() { border: 1px solid #dcdee2; background: #f8f8f9; } + &.iaf-thumb-1x1 img { + width: 48px; + height: 48px; + object-fit: contain; + } + &.iaf-thumb-4x3 img { + width: 88px; + height: 66px; + object-fit: contain; + } .iaf-thumb-del { position: absolute; top: -7px; @@ -142,6 +159,24 @@ function clearValue() { display: block; } } + .iaf-holder { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px dashed #cfd8e6; + border-radius: 4px; + color: #b4bfce; + font-size: 11px; + background: #fafbfd; + &.iaf-holder-1x1 { + width: 48px; + height: 48px; + } + &.iaf-holder-4x3 { + width: 88px; + height: 66px; + } + } .iaf-manual { max-width: 420px; margin-top: 6px; diff --git a/src/utils/siteConfigForm.ts b/src/utils/siteConfigForm.ts index fe7dbc3..3351483 100644 --- a/src/utils/siteConfigForm.ts +++ b/src/utils/siteConfigForm.ts @@ -17,6 +17,8 @@ export interface SiteConfigFieldDef { multiline?: boolean; /** url: http(s) URL;host: 域名(不带协议);asset: / 开头路径或 http(s) URL;text: 仅长度 */ kind?: "text" | "url" | "host" | "asset"; + /** F5(docs/20):asset 类字段的预览/占位框比例(favicon 1:1、二维码 4:3) */ + aspect?: "1:1" | "4:3"; maxLength?: number; } @@ -28,7 +30,7 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [ tip: "门户顶栏与页脚品牌位展示;建议先在「附件管理」上传,复制路径填到这里。", }, { - key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500, + key: "favicon", label: "浏览器图标 favicon", group: "display", kind: "asset", maxLength: 500, aspect: "1:1", placeholder: "如 /yschcms/upload/…/favicon.png(留空用主题内置图标)", tip: "浏览器标签页与收藏夹的小图标,不显示在页面正文中。", }, @@ -52,12 +54,12 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [ tip: "逐行展示在页脚品牌区旁;换行即分行(最多 500 字)。", }, { - key: "qr_wechat", label: "微信二维码", group: "footer", kind: "asset", maxLength: 500, + key: "qr_wechat", label: "微信二维码", group: "footer", kind: "asset", maxLength: 500, aspect: "4:3", placeholder: "如 /yschcms/upload/…/qr-wechat.png(留空不展示)", tip: "页脚二维码,悬停放大预览;建议先在「附件管理」上传。", }, { - key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500, + key: "qr_weibo", label: "微博二维码", group: "footer", kind: "asset", maxLength: 500, aspect: "4:3", placeholder: "如 /yschcms/upload/…/qr-weibo.png(留空不展示)", tip: "显示在页脚右侧二维码区,与微信二维码并排,悬停放大预览。", }, diff --git a/src/views/cms/components/SiteConfigForm.vue b/src/views/cms/components/SiteConfigForm.vue index 20d5133..88e9ba9 100644 --- a/src/views/cms/components/SiteConfigForm.vue +++ b/src/views/cms/components/SiteConfigForm.vue @@ -11,6 +11,7 @@ import { validateFieldValue, } from "@/utils/siteConfigForm"; import ImageAssetField from "@/components/ImageAssetField.vue"; +import FooterPreview from "@/components/FooterPreview.vue"; /** * 站点配置结构化表单(docs/09 §3.1;docs/16 §3.3 分区作用域;docs/20 F6 扩展参数区退役): @@ -120,6 +121,7 @@ function defsOf(group: string): SiteConfigFieldDef[] { v-model="formModel.fields[def.key]" :site-id="siteId" :placeholder="def.placeholder" + :aspect="def.aspect" @update:model-value="emitUp" />

{{ def.tip }}

+ +