feat(portal): 补入整站页脚,并让项目列表页不显示页脚
mirror-to-gitee / mirror (push) Successful in 4s

用户反馈 /projects/(项目列表)页底部的品牌/联系/版权区应该去掉。

背景:这几个文件此前一直未提交——远端仓库其实没有页脚,
PortalFooter.vue 只存在于本地,HEAD 版 index.ts 也没引用它。
所以本次把页脚整套补齐,再叠加「按页面决定是否展示」的逻辑。

新增/改动
- .vitepress/theme/components/PortalFooter.vue(新增,274 行)
  品牌区 + 产品体系/文档资源/关于我们/联系我们四栏 + 二维码浮层 + 版权条
- .vitepress/theme/styles/portal.css:补入全部 .pf-foot-* 样式(508 行)
  只搬运页脚相关规则,不夹带工作区里其它未提交的样式改动
- .vitepress/theme/index.ts:挂载页脚 + 页面级显隐判定
- pages/projects/index.md:frontmatter 加 footer: false

页脚显隐判定(isDocPage)
「文档型页面不挂页脚」——这类页面有左侧目录树、右侧本页目录、
底部上一页/下一页翻页,读者正在阅读流里,450px 的品牌收尾区是纯噪音。
命中任一条件即隐藏:
  1) 路径 /projects/<id>/manual/...
  2) frontmatter 有 sidebar / outline / aside
  3) frontmatter 显式 footer: false

frontmatter 的 footer 可双向覆盖(这解决了 /projects/ 的困境):
  该页有 outline: deep,按上面的规则本会被判成文档页而隐藏页脚,
  但它本质是「浏览页」,需要显式 footer: false 才符合预期。
  同理 footer: true 可强制显示。

验证(干净 clone 构建后实测)
- /projects/        无页脚(footer=false,无版权条)
- /                  有页脚(453px,渐变背景正常)
- 文档页(详情/对比/快速开始/手册)均无页脚
- 自定义整页(关于/联系/演示/官方文档)均有页脚
This commit is contained in:
Dai0814
2026-11-06 13:39:41 +08:00
parent dbe708511a
commit e4757446cf
4 changed files with 823 additions and 2 deletions
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { withBase, useData } from 'vitepress'
import { projects } from '../../../data/projects'
/**
* 站点页脚
*
* 结构对齐商业站页脚范式:左侧品牌(Logo + 定位 + 描述 + 资质标签)、
* 中部多栏链接、右侧联系方式与行动按钮、底部版权条。
* 链接全部取自注册表与内容数据,避免硬编码产生死链。
*/
/**
* 有侧边栏的页面会带上 .has-sidebar 标记(目前仅作语义钩子,不参与布局)。
*
* 页脚不再靠 padding 避让侧栏:侧栏是 fixed 且不透明,避让反而会在宽屏下留出
* 一大片空白(1440→272px、1920→512px)。现在页脚整宽居中,靠 z-index 抬到
* 侧栏之上,见 portal.css 中 .pf-foot 的说明。
*
* 判定与官方 useSidebar().hasSidebar 同源:frontmatter.sidebar !== false、
* 该路径确实命中主题 sidebar 配置、且不是 home 布局。
*/
const { frontmatter, page, theme } = useData()
const hasSidebar = computed(() => {
if (frontmatter.value.sidebar === false) return false
if (frontmatter.value.layout === 'home') return false
const cfg = theme.value.sidebar
if (!cfg) return false
// 与官方 getSidebar 一致的匹配顺序:精确 → 目录前缀 → 最长前缀
const path = '/' + (page.value.relativePath || '').replace(/\.md$/, '').replace(/\/index$/, '/')
if (Array.isArray(cfg)) return cfg.length > 0
let matched = ''
for (const key of Object.keys(cfg)) {
if (path.startsWith(key) && key.length > matched.length) matched = key
}
return matched ? cfg[matched].length > 0 : false
})
const ORG_URL = 'https://gitee.com/snowgoat'
/** 中部链接栏 */
const columns = [
{
title: '产品体系',
items: projects.map((p) => ({ text: p.name, link: withBase(p.page) })),
},
{
title: '文档资源',
items: [
{ text: '官方文档', link: withBase('/docs/') },
{ text: '开源地址', link: ORG_URL, external: true },
],
},
{
title: '关于我们',
items: [
{ text: '关于雪羊', link: withBase('/about') },
{ text: '联系我们', link: withBase('/contact') },
],
},
]
/** 右侧联系方式:电话/邮箱为点击复制;微信点击展开二维码浮层 */
const contacts = [
{ key: 'phone', label: '电话', value: '0531-85950527', icon: 'phone' },
{ key: 'mail', label: '邮箱', value: 'contact@xueyangai.com', icon: 'mail' },
]
/**
* 二维码浮层
*
* 为什么不直接把二维码摆在页脚里:这张码 33×33 模块,实测码区小于 160px
* 就会被解码器判定失败,浏览器再缩放一次更是直接扫不出。也就是说它必须
* 按 246px(198 码区 + 24 白边)1:1 显示,而这比页脚右栏(约 280px)还宽,
* 硬塞会把整栏拉得很宽、压掉其它列。
*
* 所以改成「点击展开、分层浮出」:平时只占一行文字的位置,
* 点击后在按钮上方浮出完整尺寸的二维码,扫码体验和尺寸都不打折。
*/
const qrOpen = ref(false)
const qrWrap = ref<HTMLElement | null>(null)
function toggleQr() {
qrOpen.value = !qrOpen.value
}
function closeQrOnOutside(e: MouseEvent) {
if (!qrOpen.value) return
if (qrWrap.value && !qrWrap.value.contains(e.target as Node)) qrOpen.value = false
}
function closeQrOnEsc(e: KeyboardEvent) {
if (e.key === 'Escape') qrOpen.value = false
}
onMounted(() => {
document.addEventListener('click', closeQrOnOutside)
document.addEventListener('keydown', closeQrOnEsc)
})
onUnmounted(() => {
document.removeEventListener('click', closeQrOnOutside)
document.removeEventListener('keydown', closeQrOnEsc)
})
/** 当前已复制的项(key),用于各自独立显示「已复制」 */
const copiedKey = ref('')
let copyTimer: ReturnType<typeof setTimeout> | undefined
async function copyText(text: string, key: string) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
} catch {
// 非 https / 旧浏览器:退回 textarea + execCommand
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
copiedKey.value = key
clearTimeout(copyTimer)
copyTimer = setTimeout(() => (copiedKey.value = ''), 1600)
}
onUnmounted(() => clearTimeout(copyTimer))
const year = new Date().getFullYear()
</script>
<template>
<footer class="pf-foot" :class="{ 'has-sidebar': hasSidebar }">
<div class="p-wrap">
<div class="pf-foot-main">
<!-- 品牌 -->
<div class="pf-foot-brand">
<a class="pf-foot-logo" :href="withBase('/')">
<img :src="withBase('/snowgoat-logo.png')" alt="雪羊科技" width="34" height="34" />
<span>雪羊科技</span>
</a>
<p class="pf-foot-slogan">开源物联网卡与设备管理平台</p>
<p class="pf-foot-desc">
物联网卡管理、DTU 设备管理、中后台开发底座,三个项目同一套工程规范。
全部以 Apache-2.0 发布,可自由使用、修改与商业部署。
</p>
<ul class="pf-foot-chips">
<li>Apache-2.0 开源许可</li>
<li>支持私有化部署</li>
</ul>
</div>
<!-- 链接栏 -->
<div class="pf-foot-cols">
<div v-for="col in columns" :key="col.title" class="pf-foot-col">
<h4>{{ col.title }}</h4>
<ul>
<li v-for="item in col.items" :key="item.text">
<a
:href="item.link"
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noopener' : undefined"
>{{ item.text }}</a>
</li>
</ul>
</div>
</div>
<!-- 联系 -->
<div class="pf-foot-contact">
<h4>联系我们</h4>
<!-- 各项统一:整行点击复制 -->
<div
v-for="c in contacts"
:key="c.key"
class="pf-foot-contact-row"
:data-key="c.key"
:class="{ 'is-copied': copiedKey === c.key }"
>
<span class="pf-foot-contact-icon" aria-hidden="true">
<svg v-if="c.icon === 'phone'" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.6 10.8c1.2 2.4 3.2 4.4 5.6 5.6l1.9-1.9c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.5.6.6 0 1 .4 1 1V19c0 .6-.4 1-1 1C10.4 20 4 13.6 4 5.5c0-.6.5-1 1.1-1h2.9c.6 0 1 .4 1 1 0 1.2.2 2.4.6 3.5.1.3 0 .7-.2 1l-1.8 1.8z"/>
</svg>
<svg v-else-if="c.icon === 'mail'" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 6h16c.6 0 1 .4 1 1v10c0 .6-.4 1-1 1H4c-.6 0-1-.4-1-1V7c0-.6.4-1 1-1zm8 7.2 7-4.7V7l-7 4.6L5 7v1.5l7 4.7z"/>
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor">
<path d="M8.7 4C4.9 4 1.8 6.6 1.8 9.8c0 1.8 1 3.4 2.7 4.5l-.6 2 2.2-1.1c.7.2 1.4.3 2.2.3h.5a5.6 5.6 0 0 1-.2-1.5c0-3.1 3-5.6 6.6-5.6h.5C15.2 5.6 12.3 4 8.7 4zm-2.3 3.1a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zm4.6 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8z"/>
<path d="M22.2 13.9c0-2.7-2.6-4.9-5.9-4.9s-5.9 2.2-5.9 4.9 2.6 4.9 5.9 4.9c.7 0 1.4-.1 2-.3l1.9.9-.5-1.7c1.5-.9 2.5-2.3 2.5-3.8zm-7.9-.9a.8.8 0 1 1 0-1.6.8.8 0 0 1 0 1.6zm4 0a.8.8 0 1 1 0-1.6.8.8 0 0 1 0 1.6z"/>
</svg>
</span>
<button
type="button"
class="pf-foot-contact-text pf-foot-contact-copy"
:title="`复制${c.label} ${c.value}`"
@click="copyText(c.value, c.key)"
>
<span class="pf-foot-contact-label">{{ c.label }}</span>
<span class="pf-foot-contact-value">
{{ c.value }}
<em class="pf-foot-copy-hint">{{ copiedKey === c.key ? '已复制' : '复制' }}</em>
</span>
</button>
</div>
<!--
企业微信:平时只占一行,点击向上浮出完整二维码。
原因见 script 里 qrOpen 上方的注释:二维码必须 246px 1:1 显示,
比右栏还宽,直接摆进去会把整栏拉宽、挤压其它列。
-->
<div ref="qrWrap" class="pf-foot-qr">
<button
type="button"
class="pf-foot-contact-text pf-foot-qr-trigger"
:class="{ 'is-open': qrOpen }"
:aria-expanded="qrOpen"
aria-haspopup="dialog"
@click="toggleQr"
>
<span class="pf-foot-contact-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M8.7 4C4.9 4 1.8 6.6 1.8 9.8c0 1.8 1 3.4 2.7 4.5l-.6 2 2.2-1.1c.7.2 1.4.3 2.2.3h.5a5.6 5.6 0 0 1-.2-1.5c0-3.1 3-5.6 6.6-5.6h.5C15.2 5.6 12.3 4 8.7 4zm-2.3 3.1a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zm4.6 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8z"/>
<path d="M22.2 13.9c0-2.7-2.6-4.9-5.9-4.9s-5.9 2.2-5.9 4.9 2.6 4.9 5.9 4.9c.7 0 1.4-.1 2-.3l1.9.9-.5-1.7c1.5-.9 2.5-2.3 2.5-3.8zm-7.9-.9a.8.8 0 1 1 0-1.6.8.8 0 0 1 0 1.6zm4 0a.8.8 0 1 1 0-1.6.8.8 0 0 1 0 1.6z"/>
</svg>
</span>
<span class="pf-foot-contact-label">企业微信</span>
<span class="pf-foot-contact-value pf-foot-qr-value">
扫码添加
<em class="pf-foot-copy-hint">{{ qrOpen ? '收起' : '二维码' }}</em>
</span>
</button>
<div v-show="qrOpen" class="pf-foot-qr-pop" role="dialog" aria-label="企业微信二维码">
<img
:src="withBase('/wechat-qr.png')"
alt="雪羊科技企业微信二维码,扫码添加好友"
width="246"
height="246"
loading="lazy"
decoding="async"
/>
<span class="pf-foot-qr-pop-hint">微信扫一扫,添加好友</span>
</div>
</div>
<a class="p-btn p-btn--primary pf-foot-btn" :href="withBase('/demo')">
进入系统演示
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
</div>
</div>
<!-- 版权条 -->
<div class="pf-foot-bottom">
<span>Copyright © {{ year }} 雪羊科技 · All Rights Reserved.</span>
<span class="pf-foot-bottom-right">
<a :href="ORG_URL" target="_blank" rel="noopener">开源项目</a>
<i aria-hidden="true">·</i>
<span>Apache-2.0</span>
</span>
</div>
</div>
</footer>
</template>