[CMS-VISUAL] F5: 页脚结构示意图(六处位置标注+实时回显)+ favicon 1:1/二维码 4:3 占位框与比例预览(180 测试绿)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user