feat: 优化文章详情与导航配置
This commit is contained in:
@@ -43,8 +43,7 @@ img { max-width: 100%; }
|
||||
.site-header { position: absolute; z-index: 20; top: 0; left: 0; display: grid; grid-template-columns: 250px 1fr 160px; align-items: center; width: 100%; padding: 26px 4.2vw; }
|
||||
.main-nav { display: flex; justify-content: center; gap: clamp(18px, 2.4vw, 42px); }
|
||||
.main-nav a { position: relative; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; min-height: 54px; padding: 0 3px; color: #17364e; white-space: nowrap; }
|
||||
.nav-label { font-size: 14px; line-height: 1.2; }
|
||||
.nav-english { margin-top: 5px; color: #71889b; font-size: 9px; font-weight: 500; line-height: 1; letter-spacing: .12em; }
|
||||
.nav-label { font-size: var(--nav-font-size, 14px); line-height: 1.2; }
|
||||
.main-nav a::after { position: absolute; right: 50%; bottom: -8px; left: 50%; height: 1px; content: ""; background: var(--ink); transition: .28s ease; }
|
||||
.main-nav a:hover::after, .main-nav a:focus-visible::after, .main-nav a[aria-current="page"]::after { right: 0; left: 0; }
|
||||
.header-cta, .primary-button { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 11px; overflow: hidden; border-radius: 999px; color: white; background: linear-gradient(135deg, #2b5f8b, #0a3456); box-shadow: 0 12px 28px rgba(10, 52, 86, .2); isolation: isolate; }
|
||||
@@ -180,8 +179,7 @@ img { max-width: 100%; }
|
||||
.story-page { max-width: 1060px; margin: 0 auto; padding: 72px 32px 130px; }
|
||||
.story-page header { text-align: center; }
|
||||
.story-page header > p { color: var(--muted); }
|
||||
.story-page header > img { width: 100%; max-height: 530px; margin-top: 48px; border-radius: 34px; object-fit: cover; box-shadow: var(--shadow); }
|
||||
.story-body { max-width: 760px; margin: 58px auto 0; }
|
||||
.story-body { width: 100%; margin: 58px 0 0; }
|
||||
.detail-back { display: inline-flex; margin-bottom: 34px; color: #59758c; font-size: 13px; }
|
||||
.related-content { max-width: 760px; margin: 90px auto 0; padding-top: 42px; border-top: 1px solid var(--line); }
|
||||
.related-content h2 { margin: 0 0 20px; font: 500 34px var(--serif); }
|
||||
@@ -430,8 +428,7 @@ img { max-width: 100%; }
|
||||
.main-nav { position: fixed; top: 0; left: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100vw; height: 100svh; gap: 20px; visibility: hidden; opacity: 0; background: rgba(247,249,251,.97); transition: visibility .28s ease, opacity .28s ease; }
|
||||
.main-nav.open { visibility: visible; opacity: 1; }
|
||||
.main-nav a { display: flex; min-width: 220px; min-height: 56px; justify-content: center; }
|
||||
.main-nav .nav-label { font: 500 23px/1.15 var(--serif); }
|
||||
.main-nav .nav-english { margin-top: 6px; font: 500 9px/1 var(--sans); letter-spacing: .18em; }
|
||||
.main-nav .nav-label { font-family: var(--serif); font-size: var(--nav-font-size, 14px); font-weight: 500; line-height: 1.15; }
|
||||
.header-cta { display: none; }
|
||||
.teacher-card { grid-template-columns: .9fr 1.1fr; }
|
||||
.official-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
|
||||
@@ -6,7 +6,7 @@ defineProps<{ site: SiteDocument }>()
|
||||
|
||||
<template>
|
||||
<div class="public-page-shell">
|
||||
<SiteHeader :logo="site.brand.logo" :brand-name="site.brand.name" :english-name="site.brand.englishName" />
|
||||
<SiteHeader :logo="site.brand.logo" :brand-name="site.brand.name" :english-name="site.brand.englishName" :nav-font-size="site.brand.navFontSize" />
|
||||
<main id="main-content" class="public-page-main"><slot /></main>
|
||||
<footer id="contact" class="site-footer">
|
||||
<div class="footer-brand"><BrandLogo :src="site.brand.logo" :name="site.brand.name" :english-name="site.brand.englishName" /><p>{{ site.brand.subSlogan }}</p></div>
|
||||
|
||||
@@ -4,19 +4,20 @@ import { PhArrowRight as ArrowRight, PhList as List, PhX as X } from '@phosphor-
|
||||
const open = ref(false)
|
||||
const toggle = ref<HTMLButtonElement>()
|
||||
const navigation = ref<HTMLElement>()
|
||||
const props = withDefaults(defineProps<{ logo?: string; brandName?: string; englishName?: string }>(), {
|
||||
const props = withDefaults(defineProps<{ logo?: string; brandName?: string; englishName?: string; navFontSize?: number }>(), {
|
||||
brandName: '慧遇书院',
|
||||
englishName: 'HUIYU ACADEMY',
|
||||
navFontSize: 14,
|
||||
})
|
||||
const route = useRoute()
|
||||
const links = computed(() => [
|
||||
{ label: '首页', englishLabel: 'HOME', href: '/#home' },
|
||||
{ label: `关于${props.brandName}`, englishLabel: `ABOUT ${props.englishName}`, href: '/#about' },
|
||||
{ label: '卢慧老师', englishLabel: 'LU HUI', href: '/luhui' },
|
||||
{ label: '课程与产品', englishLabel: 'COURSES & PRODUCTS', href: '/products' },
|
||||
{ label: '官方信息', englishLabel: 'OFFICIAL INFO', href: '/#official' },
|
||||
{ label: '品牌动态', englishLabel: 'BRAND NEWS', href: '/news' },
|
||||
{ label: '联系我们', englishLabel: 'CONTACT US', href: '/#contact' },
|
||||
{ label: '首页', href: '/#home' },
|
||||
{ label: `关于${props.brandName}`, href: '/#about' },
|
||||
{ label: '卢慧老师', href: '/luhui' },
|
||||
{ label: '课程与产品', href: '/products' },
|
||||
{ label: '官方信息', href: '/#official' },
|
||||
{ label: '品牌动态', href: '/news' },
|
||||
{ label: '联系我们', href: '/#contact' },
|
||||
])
|
||||
|
||||
function closeMenu(returnFocus = false) {
|
||||
@@ -41,7 +42,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="site-header">
|
||||
<header class="site-header" :style="{ '--nav-font-size': `${navFontSize}px` }">
|
||||
<BrandLogo :src="logo" :name="brandName" :english-name="englishName" />
|
||||
<button ref="toggle" class="menu-toggle" type="button" :aria-expanded="open" aria-controls="site-navigation" :aria-label="open ? '关闭导航' : '打开导航'" @click="open = !open" @keydown.esc="closeMenu(true)">
|
||||
<X v-if="open" :size="24" />
|
||||
@@ -50,7 +51,6 @@ onBeforeUnmount(() => {
|
||||
<nav id="site-navigation" ref="navigation" :class="['main-nav', { open }]" aria-label="主导航" @keydown.esc="closeMenu(true)">
|
||||
<a v-for="link in links" :key="link.href" :href="link.href" :aria-current="isActive(link.href) ? 'page' : undefined" @click="closeMenu()">
|
||||
<span class="nav-label">{{ link.label }}</span>
|
||||
<span class="nav-english" lang="en">{{ link.englishLabel }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
<a class="header-cta" href="/#official">
|
||||
|
||||
@@ -60,7 +60,7 @@ async function changePassword() {
|
||||
<div v-if="loading" class="admin-loading">正在加载设置…</div>
|
||||
<div v-else-if="document" class="settings-admin-body">
|
||||
<section class="editor-panel"><div class="panel-heading"><div><span>01</span><h1>公司主体信息</h1></div><small>正式上线前必须核验</small></div><div class="form-grid">
|
||||
<label>官网展示名称<input v-model="document.brand.name" placeholder="例如:慧遇书院"></label><label>公司主体全称<input v-model="document.brand.companyName" placeholder="营业执照登记名称"></label><label>统一社会信用代码<input v-model="document.brand.creditCode"></label><label>版权信息<input v-model="document.brand.copyright"></label><label>ICP备案<input v-model="document.brand.icp"></label><label>公安备案<input v-model="document.brand.policeRecord"></label><label class="full">公司介绍<textarea v-model="document.brand.introduction" rows="5" /></label>
|
||||
<label>官网展示名称<input v-model="document.brand.name" placeholder="例如:慧遇书院"></label><label>导航字体大小(px)<input v-model.number="document.brand.navFontSize" type="number" min="12" max="28" step="1"></label><label>公司主体全称<input v-model="document.brand.companyName" placeholder="营业执照登记名称"></label><label>统一社会信用代码<input v-model="document.brand.creditCode"></label><label>版权信息<input v-model="document.brand.copyright"></label><label>ICP备案<input v-model="document.brand.icp"></label><label>公安备案<input v-model="document.brand.policeRecord"></label><label class="full">公司介绍<textarea v-model="document.brand.introduction" rows="5" /></label>
|
||||
</div></section>
|
||||
<section class="editor-panel"><div class="panel-heading"><div><span>02</span><h2>卢慧老师完整资料</h2></div><small>请以本人确认内容为准</small></div><div class="form-grid">
|
||||
<label class="full">完整介绍<textarea v-model="document.teacher.fullBio" rows="12" /></label><label class="full">标签(用中文逗号分隔)<input :value="document.teacher.tags.join(',')" @input="document.teacher.tags = ($event.target as HTMLInputElement).value.split(/[,,]/).map(v => v.trim()).filter(Boolean).slice(0, 6)"></label><label class="full">代表成果(每行一项)<textarea :value="document.teacher.achievements.join('\n')" rows="6" @input="document.teacher.achievements = ($event.target as HTMLTextAreaElement).value.split('\n').map(v => v.trim()).filter(Boolean).slice(0, 12)" /></label>
|
||||
|
||||
@@ -90,7 +90,7 @@ onBeforeUnmount(() => {
|
||||
<source v-if="usesDefaultHero" srcset="/images/hero-bg-v3.webp" type="image/webp">
|
||||
<img :src="heroImageSource" alt="" width="1672" height="941" fetchpriority="high" decoding="async" @error="useHeroFallback">
|
||||
</picture>
|
||||
<SiteHeader :logo="site.brand.logo" :brand-name="site.brand.name" :english-name="site.brand.englishName" />
|
||||
<SiteHeader :logo="site.brand.logo" :brand-name="site.brand.name" :english-name="site.brand.englishName" :nav-font-size="site.brand.navFontSize" />
|
||||
<div class="hero-copy">
|
||||
<h1><span>{{ site.hero.titleLine1 }}</span><span>{{ site.hero.titleLine2 }}</span></h1>
|
||||
<p class="hero-subtitle">{{ site.hero.subtitle }}</p>
|
||||
|
||||
@@ -6,4 +6,4 @@ if (!article.value) throw createError({ statusCode: 404, statusMessage: '未找
|
||||
useSeoMeta({ title: () => `${article.value!.title}|${site.value.brand.name}`, description: () => article.value!.summary, ogTitle: () => article.value!.title, ogDescription: () => article.value!.summary, ogImage: () => absoluteUrl(article.value!.cover), articlePublishedTime: () => article.value!.publishedAt })
|
||||
useHead(() => ({ script: [{ type: 'application/ld+json', textContent: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Article', headline: article.value!.title, description: article.value!.summary, datePublished: article.value!.publishedAt, image: absoluteUrl(article.value!.cover), publisher: { '@type': 'Organization', name: site.value.brand.companyName, logo: { '@type': 'ImageObject', url: absoluteUrl(site.value.brand.logo) } } }) }] }))
|
||||
</script>
|
||||
<template><PublicPageLayout :site="site"><article v-if="article" class="story-page"><NuxtLink class="detail-back" :to="{ path: '/news', query: isPreview ? { preview: 'draft' } : {} }">← 返回品牌动态</NuxtLink><header><p class="eyebrow">BRAND NEWS</p><h1>{{ article.title }}</h1><p>{{ article.publishedAt }}<span v-if="article.sourceName"> · {{ article.sourceName }}</span></p><img :src="article.cover" :alt="article.title" width="1536" height="1024" decoding="async"></header><div class="story-body"><p class="lead">{{ article.summary }}</p><RichTextContent :content="article.content" /><a v-if="article.sourceUrl" :href="article.sourceUrl" target="_blank" rel="noopener noreferrer" class="text-link">查看原文 ↗</a></div><aside v-if="related.length" class="related-content"><p class="eyebrow">RELATED</p><h2>继续阅读</h2><NuxtLink v-for="item in related" :key="item.slug" :to="{ path: `/news/${item.slug}`, query: isPreview ? { preview: 'draft' } : {} }"><span>{{ item.title }}</span><time>{{ item.publishedAt }}</time></NuxtLink></aside></article></PublicPageLayout></template>
|
||||
<template><PublicPageLayout :site="site"><article v-if="article" class="story-page"><NuxtLink class="detail-back" :to="{ path: '/news', query: isPreview ? { preview: 'draft' } : {} }">← 返回品牌动态</NuxtLink><header><p class="eyebrow">BRAND NEWS</p><h1>{{ article.title }}</h1><p>{{ article.publishedAt }}<span v-if="article.sourceName"> · {{ article.sourceName }}</span></p></header><div class="story-body"><p class="lead">{{ article.summary }}</p><RichTextContent :content="article.content" /><a v-if="article.sourceUrl" :href="article.sourceUrl" target="_blank" rel="noopener noreferrer" class="text-link">查看原文 ↗</a></div><aside v-if="related.length" class="related-content"><p class="eyebrow">RELATED</p><h2>继续阅读</h2><NuxtLink v-for="item in related" :key="item.slug" :to="{ path: `/news/${item.slug}`, query: isPreview ? { preview: 'draft' } : {} }"><span>{{ item.title }}</span><time>{{ item.publishedAt }}</time></NuxtLink></aside></article></PublicPageLayout></template>
|
||||
|
||||
@@ -7,4 +7,4 @@ if (!product.value) throw createError({ statusCode: 404, statusMessage: '未找
|
||||
useSeoMeta({ title: () => `${product.value!.title}|${site.value.brand.name}`, description: () => product.value!.summary, ogTitle: () => product.value!.title, ogDescription: () => product.value!.summary, ogImage: () => absoluteUrl(product.value!.image) })
|
||||
useHead(() => ({ script: [{ type: 'application/ld+json', textContent: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Course', name: product.value!.title, description: product.value!.summary, provider: { '@type': 'Organization', name: site.value.brand.companyName, url: absoluteUrl('/') }, image: absoluteUrl(product.value!.image) }) }] }))
|
||||
</script>
|
||||
<template><PublicPageLayout :site="site"><article v-if="product" class="story-page"><NuxtLink class="detail-back" :to="{ path: '/products', query: isPreview ? { preview: 'draft' } : {} }">← 返回课程与产品</NuxtLink><header><p class="eyebrow">{{ product.type === 'tool' ? 'TECH PRODUCT' : 'COURSE' }}</p><h1>{{ product.title }}</h1><p>{{ product.subtitle }}</p><img :src="product.image" :alt="product.title" width="1536" height="1024" loading="eager" decoding="async"></header><div class="story-body"><p class="lead">{{ product.summary }}</p><RichTextContent :content="product.content" /><a class="primary-button article-consult" href="/#contact">咨询了解</a></div></article></PublicPageLayout></template>
|
||||
<template><PublicPageLayout :site="site"><article v-if="product" class="story-page"><NuxtLink class="detail-back" :to="{ path: '/products', query: isPreview ? { preview: 'draft' } : {} }">← 返回课程与产品</NuxtLink><header><p class="eyebrow">{{ product.type === 'tool' ? 'TECH PRODUCT' : 'COURSE' }}</p><h1>{{ product.title }}</h1><p>{{ product.subtitle }}</p></header><div class="story-body"><p class="lead">{{ product.summary }}</p><RichTextContent :content="product.content" /><a class="primary-button article-consult" href="/#contact">咨询了解</a></div></article></PublicPageLayout></template>
|
||||
|
||||
@@ -65,6 +65,7 @@ const siteDocumentBaseSchema = z.object({
|
||||
copyright: z.string().max(100).default('© 2026 慧遇书院 版权所有'),
|
||||
icp: z.string().max(80).default('备案信息待配置'),
|
||||
policeRecord: z.string().max(80).default(''),
|
||||
navFontSize: z.number().int().min(12).max(28).default(14),
|
||||
}),
|
||||
hero: z.object({
|
||||
titleLine1: z.string().min(1).max(20),
|
||||
@@ -138,6 +139,7 @@ export const defaultSiteDocument: SiteDocument = {
|
||||
copyright: '© 2026 慧遇书院 版权所有',
|
||||
icp: 'ICP备案信息待配置',
|
||||
policeRecord: '',
|
||||
navFontSize: 14,
|
||||
},
|
||||
hero: {
|
||||
titleLine1: '身心疗愈',
|
||||
|
||||
@@ -5,6 +5,13 @@ describe('siteDocumentSchema', () => {
|
||||
it('accepts the seeded phase-one website document', () => {
|
||||
expect(siteDocumentSchema.parse(defaultSiteDocument)).toEqual(defaultSiteDocument)
|
||||
expect(defaultSiteDocument.brand.name).toBe('慧遇书院')
|
||||
expect(defaultSiteDocument.brand.navFontSize).toBe(14)
|
||||
})
|
||||
|
||||
it('keeps the navigation font size within a readable range', () => {
|
||||
const invalid = structuredClone(defaultSiteDocument)
|
||||
invalid.brand.navFontSize = 36
|
||||
expect(siteDocumentSchema.safeParse(invalid).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unsafe oversized content', () => {
|
||||
|
||||
Reference in New Issue
Block a user