[CMS-VISUAL] F5: 页脚结构示意图(六处位置标注+实时回显)+ favicon 1:1/二维码 4:3 占位框与比例预览(180 测试绿)

This commit is contained in:
chenxing
2026-09-28 14:59:05 +08:00
parent 49678a6568
commit 6334b24d34
5 changed files with 337 additions and 4 deletions
+107
View File
@@ -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");
});
});
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { assetUrl } from "@/api/module/cms";
/**
* F5(docs/20)页脚结构示意图:页脚 Tab 内的固定示意图,标注各字段在门户页脚中的视觉位置——
* 品牌区(站名+联系方式+多校区地址行)/ 二维码区(微信+微博,4:3)/ 社媒图标行 / 版权备案行。
* 已填字段实时回显内容(填完即知页脚长什么样);空字段以灰字占位标注。
*/
const props = defineProps<{
contact?: string;
footerAddresses?: string;
qrWechat?: string;
qrWeibo?: string;
socials?: { label: string; value?: string }[];
copyright?: string;
beian?: string;
}>();
const addressLines = (props.footerAddresses || "")
.split(/\r?\n/)
.map(l => l.trim())
.filter(Boolean)
.slice(0, 3);
</script>
<template>
<div class="footer-preview">
<div class="fp-caption">页脚结构示意(下图与字段一一对应,已填内容实时回显)</div>
<div class="fp-frame" role="img" aria-label="页脚结构示意图">
<div class="fp-brand">
<div class="fp-logo">
<span class="fp-logo-mark">LOGO</span>
<span class="fp-site">XX学校</span>
</div>
<p class="fp-line" :class="{ filled: contact }">
{{ contact || "① 联系方式(页脚品牌区)" }}
</p>
<p v-for="(l, i) in addressLines" :key="i" class="fp-line small filled">{{ l }}</p>
<p v-if="!addressLines.length" class="fp-line small">② 多校区地址(每行一个校区)</p>
</div>
<div class="fp-qr">
<div class="fp-qr-box">
<img v-if="qrWechat" :src="assetUrl(qrWechat)" alt="微信二维码预览" />
<span v-else>微信二维码<br />③ 4:3</span>
</div>
<div class="fp-qr-box">
<img v-if="qrWeibo" :src="assetUrl(qrWeibo)" alt="微博二维码预览" />
<span v-else>微博二维码<br />③ 4:3</span>
</div>
</div>
<div class="fp-social">
<span class="fp-social-label">④ 社媒图标行:</span>
<span v-for="s in socials" :key="s.label" class="fp-social-item" :class="{ on: s.value }">
{{ s.label }}
</span>
</div>
<div class="fp-bottom">
<span :class="{ filled: copyright }">{{ copyright || "⑤ 版权文字" }}</span>
<span :class="{ filled: beian }" class="fp-beian">{{ beian || "⑥ 备案号" }}</span>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.footer-preview {
margin-top: 6px;
.fp-caption {
color: #8a93a6;
font-size: 12px;
margin-bottom: 8px;
}
.fp-frame {
border: 1px solid #e9edf5;
border-radius: 8px;
background: linear-gradient(180deg, #1f3a5f 0%, #16294a 100%);
color: rgba(255, 255, 255, 0.5);
padding: 18px 22px 12px;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px 18px;
font-size: 12px;
line-height: 1.7;
.fp-brand {
.fp-logo {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
.fp-logo-mark {
border: 1px dashed rgba(255, 255, 255, 0.35);
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
}
.fp-site {
color: rgba(255, 255, 255, 0.75);
font-weight: 600;
}
}
.fp-line {
margin: 2px 0;
&.small {
font-size: 11px;
opacity: 0.9;
}
&.filled {
color: #fff;
}
}
}
.fp-qr {
display: flex;
gap: 12px;
align-items: flex-start;
.fp-qr-box {
width: 84px;
height: 63px;
border: 1px dashed rgba(255, 255, 255, 0.4);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 10px;
position: relative;
overflow: hidden;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
}
}
.fp-social {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.fp-social-label {
color: rgba(255, 255, 255, 0.45);
}
.fp-social-item {
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
padding: 1px 10px;
font-size: 11px;
opacity: 0.55;
&.on {
opacity: 1;
border-color: rgba(255, 255, 255, 0.85);
color: #fff;
}
}
}
.fp-bottom {
grid-column: 1 / -1;
border-top: 1px solid rgba(255, 255, 255, 0.15);
padding-top: 8px;
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 11px;
.filled {
color: rgba(255, 255, 255, 0.85);
}
}
}
}
</style>
+36 -1
View File
@@ -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() {
>
<Button size="small" icon="ios-cloud-upload-outline" :loading="uploading">上传图片</Button>
</Upload>
<span v-else class="iaf-thumb">
<span v-else class="iaf-thumb" :class="aspect ? 'iaf-thumb-' + aspect.replace(':', 'x') : ''">
<img :src="previewSrc" alt="" />
<span class="iaf-thumb-del" title="清除" @click="clearValue">
<Icon type="ios-close" />
</span>
</span>
<!-- F5:无值时按字段比例显示虚线占位框(尺寸感示意) -->
<span v-if="!modelValue && aspect" class="iaf-holder" :class="'iaf-holder-' + aspect!.replace(':', 'x')">
{{ aspect }} 位
</span>
<Button size="small" type="text" class="iaf-toggle" @click="manualOpen = !manualOpen">
{{ manualOpen ? "收起" : "手动粘贴路径" }}
</Button>
@@ -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;
+5 -3
View File
@@ -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: "显示在页脚右侧二维码区,与微信二维码并排,悬停放大预览。",
},
@@ -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"
/>
<Input
@@ -138,6 +140,22 @@ function defsOf(group: string): SiteConfigFieldDef[] {
/>
<p v-if="def.tip" class="cfg-tip">{{ def.tip }}</p>
</FormItem>
<!-- F5(docs/20):页脚分组底部固定结构示意图(字段↔视觉位置一一对应,实时回显) -->
<FooterPreview
v-if="g.name === 'footer'"
:contact="formModel.fields.contact"
:footer-addresses="formModel.fields.footer_addresses"
:qr-wechat="formModel.fields.qr_wechat"
:qr-weibo="formModel.fields.qr_weibo"
:socials="[
{ label: '微信', value: formModel.fields.social_wechat },
{ label: '微博', value: formModel.fields.social_weibo },
{ label: '抖音', value: formModel.fields.social_douyin },
{ label: 'B站', value: formModel.fields.social_bilibili },
]"
:copyright="formModel.fields.copyright"
:beian="formModel.fields.beian"
/>
</template>
</Form>
</div>