feat: 初始化慧遇书院官网一期
This commit is contained in:
23
app/components/AdminSidebar.vue
Normal file
23
app/components/AdminSidebar.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { PhArticle, PhHouse, PhImageSquare, PhSignOut, PhSlidersHorizontal } from '@phosphor-icons/vue'
|
||||
|
||||
defineProps<{ active: 'home' | 'content' | 'media' | 'settings'; logo?: string; brandName?: string; englishName?: string }>()
|
||||
|
||||
async function logout() {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
await navigateTo('/admin/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="admin-sidebar">
|
||||
<BrandLogo :src="logo" :name="brandName" :english-name="englishName" />
|
||||
<nav>
|
||||
<NuxtLink to="/admin" :class="{ active: active === 'home' }"><PhHouse :size="21" />首页管理</NuxtLink>
|
||||
<NuxtLink to="/admin/content" :class="{ active: active === 'content' }"><PhArticle :size="21" />内容管理</NuxtLink>
|
||||
<NuxtLink to="/admin/media" :class="{ active: active === 'media' }"><PhImageSquare :size="21" />素材中心</NuxtLink>
|
||||
<NuxtLink to="/admin/settings" :class="{ active: active === 'settings' }"><PhSlidersHorizontal :size="21" />网站设置</NuxtLink>
|
||||
</nav>
|
||||
<button type="button" @click="logout"><PhSignOut :size="20" />退出登录</button>
|
||||
</aside>
|
||||
</template>
|
||||
32
app/components/AnimatedMetric.vue
Normal file
32
app/components/AnimatedMetric.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ value: string }>()
|
||||
const root = ref<HTMLElement>()
|
||||
const shown = ref(props.value)
|
||||
let observer: IntersectionObserver | undefined
|
||||
|
||||
onMounted(() => {
|
||||
const match = props.value.match(/^(\d+)(.*)$/)
|
||||
if (!match || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
const target = Number(match[1])
|
||||
const suffix = match[2]
|
||||
shown.value = `0${suffix}`
|
||||
observer = new IntersectionObserver(([entry]) => {
|
||||
if (!entry?.isIntersecting) return
|
||||
observer?.disconnect()
|
||||
const startedAt = performance.now()
|
||||
const duration = 620
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - startedAt) / duration)
|
||||
const eased = 1 - Math.pow(1 - progress, 3)
|
||||
shown.value = `${Math.round(target * eased)}${suffix}`
|
||||
if (progress < 1) requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}, { threshold: 0.7 })
|
||||
if (root.value) observer.observe(root.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
</script>
|
||||
|
||||
<template><strong ref="root">{{ shown }}</strong></template>
|
||||
43
app/components/BrandLogo.vue
Normal file
43
app/components/BrandLogo.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
src?: string
|
||||
name?: string
|
||||
englishName?: string
|
||||
}>(), {
|
||||
src: '/images/brand-mark-v2.png',
|
||||
name: '慧遇书院',
|
||||
englishName: 'HUIYU ACADEMY',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink to="/" class="brand-logo" :aria-label="`返回${name}首页`">
|
||||
<img :src="src" :alt="name">
|
||||
<span class="brand-logo-copy"><strong>{{ name }}</strong><small v-if="englishName">{{ englishName }}</small></span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brand-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand-logo img {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-logo-copy { display: grid; margin-left: 10px; line-height: 1; white-space: nowrap; }
|
||||
.brand-logo-copy strong { font: 600 23px/1.15 var(--serif); letter-spacing: .08em; }
|
||||
.brand-logo-copy small { margin-top: 3px; font-size: 8px; letter-spacing: .12em; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.brand-logo img { width: 38px; height: 38px; }
|
||||
.brand-logo-copy { margin-left: 8px; }
|
||||
.brand-logo-copy strong { font-size: 18px; }
|
||||
.brand-logo-copy small { font-size: 7px; }
|
||||
}
|
||||
</style>
|
||||
21
app/components/FilingLinks.vue
Normal file
21
app/components/FilingLinks.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { PhShieldCheck as ShieldCheck } from '@phosphor-icons/vue'
|
||||
import { getPoliceFilingUrl, MIIT_FILING_URL } from '~/utils/filing'
|
||||
|
||||
const props = defineProps<{ icp: string; policeRecord?: string }>()
|
||||
const policeRecord = computed(() => props.policeRecord?.trim() || '')
|
||||
const policeFilingUrl = computed(() => getPoliceFilingUrl(policeRecord.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="filing-links">
|
||||
<a :href="MIIT_FILING_URL" target="_blank" rel="noopener noreferrer">{{ icp }}</a>
|
||||
<template v-if="policeRecord">
|
||||
<span aria-hidden="true">·</span>
|
||||
<a class="police-filing-link" :href="policeFilingUrl" target="_blank" rel="noopener noreferrer">
|
||||
<ShieldCheck :size="16" weight="fill" aria-hidden="true" />
|
||||
{{ policeRecord }}
|
||||
</a>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
61
app/components/MediaPicker.vue
Normal file
61
app/components/MediaPicker.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { PhCheck, PhImageSquare, PhMagnifyingGlass, PhX } from '@phosphor-icons/vue'
|
||||
|
||||
interface MediaAsset {
|
||||
id: number
|
||||
originalName: string
|
||||
url: string
|
||||
thumbnailUrl: string
|
||||
altText: string
|
||||
authorizationStatus: 'pending' | 'authorized' | 'restricted'
|
||||
}
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const props = withDefaults(defineProps<{ label?: string }>(), { label: '从素材库选择' })
|
||||
const open = ref(false)
|
||||
const loading = ref(false)
|
||||
const query = ref('')
|
||||
const items = ref<MediaAsset[]>([])
|
||||
const filtered = computed(() => {
|
||||
const keyword = query.value.trim().toLowerCase()
|
||||
return keyword ? items.value.filter(item => `${item.originalName} ${item.altText}`.toLowerCase().includes(keyword)) : items.value
|
||||
})
|
||||
|
||||
async function show() {
|
||||
open.value = true
|
||||
if (items.value.length) return
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = (await $fetch<{ data: MediaAsset[] }>('/api/admin/media', { query: { status: 'active' } })).data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function select(item: MediaAsset) {
|
||||
model.value = item.url
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="media-picker-trigger" @click="show"><PhImageSquare :size="17" />{{ props.label }}</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="media-picker-backdrop" role="presentation" @click.self="open = false">
|
||||
<section class="media-picker-dialog" role="dialog" aria-modal="true" aria-labelledby="media-picker-title" @keydown.esc="open = false">
|
||||
<header><div><small>MEDIA LIBRARY</small><h2 id="media-picker-title">选择素材</h2></div><button type="button" aria-label="关闭素材选择" @click="open = false"><PhX :size="22" /></button></header>
|
||||
<label class="media-picker-search"><PhMagnifyingGlass :size="18" /><input v-model="query" autofocus placeholder="搜索文件名或 ALT 描述"></label>
|
||||
<div v-if="loading" class="media-picker-empty">正在读取素材…</div>
|
||||
<div v-else-if="!filtered.length" class="media-picker-empty">素材库暂无可选图片</div>
|
||||
<div v-else class="media-picker-grid">
|
||||
<button v-for="item in filtered" :key="item.id" type="button" :class="{ selected: model === item.url }" @click="select(item)">
|
||||
<img :src="item.thumbnailUrl" :alt="item.altText || item.originalName" loading="lazy">
|
||||
<span>{{ item.altText || item.originalName }}</span>
|
||||
<small>{{ item.authorizationStatus === 'authorized' ? '已授权' : item.authorizationStatus === 'restricted' ? '限制使用' : '待核验' }}</small>
|
||||
<PhCheck v-if="model === item.url" :size="18" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
24
app/components/PublicPageLayout.vue
Normal file
24
app/components/PublicPageLayout.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { SiteDocument } from '../../server/utils/site-schema'
|
||||
|
||||
defineProps<{ site: SiteDocument }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="public-page-shell">
|
||||
<SiteHeader :logo="site.brand.logo" :brand-name="site.brand.name" :english-name="site.brand.englishName" />
|
||||
<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>
|
||||
<div class="footer-contact">
|
||||
<p><a v-if="!/[待演示]/.test(site.contact.phone)" :href="`tel:${site.contact.phone.replace(/\s/g, '')}`">{{ site.contact.phone }}</a><span v-else>{{ site.contact.phone }}</span></p>
|
||||
<p><a v-if="site.contact.email.includes('@') && !site.contact.email.includes('演示')" :href="`mailto:${site.contact.email}`">{{ site.contact.email }}</a><span v-else>{{ site.contact.email }}</span></p>
|
||||
<p>{{ site.contact.address }}</p>
|
||||
</div>
|
||||
<div class="footer-meta">
|
||||
<span>{{ site.brand.copyright }}</span>
|
||||
<span><NuxtLink to="/privacy">隐私政策</NuxtLink> · <NuxtLink to="/terms">服务条款</NuxtLink> · <FilingLinks :icp="site.brand.icp" :police-record="site.brand.policeRecord" /></span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
27
app/components/RevealSection.vue
Normal file
27
app/components/RevealSection.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
const root = ref<HTMLElement>()
|
||||
const visible = ref(false)
|
||||
let observer: IntersectionObserver | undefined
|
||||
|
||||
onMounted(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
visible.value = true
|
||||
return
|
||||
}
|
||||
observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
visible.value = true
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, { threshold: 0.16 })
|
||||
if (root.value) observer.observe(root.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="root" :class="['reveal-section', { visible }]">
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
57
app/components/SiteHeader.vue
Normal file
57
app/components/SiteHeader.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { PhArrowRight as ArrowRight, PhList as List, PhX as X } from '@phosphor-icons/vue'
|
||||
|
||||
const open = ref(false)
|
||||
const toggle = ref<HTMLButtonElement>()
|
||||
const navigation = ref<HTMLElement>()
|
||||
const props = withDefaults(defineProps<{ logo?: string; brandName?: string; englishName?: string }>(), {
|
||||
brandName: '慧遇书院',
|
||||
})
|
||||
const route = useRoute()
|
||||
const links = computed(() => [
|
||||
{ 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) {
|
||||
open.value = false
|
||||
if (returnFocus) nextTick(() => toggle.value?.focus())
|
||||
}
|
||||
|
||||
function isActive(href: string) {
|
||||
if (href.startsWith('/#')) return route.path === '/' && (href === '/#home' || route.hash === href.slice(1))
|
||||
return route.path === href || route.path.startsWith(`${href}/`)
|
||||
}
|
||||
|
||||
watch(open, (value) => {
|
||||
if (!import.meta.client) return
|
||||
document.body.classList.toggle('nav-open', value)
|
||||
if (value) nextTick(() => navigation.value?.querySelector<HTMLAnchorElement>('a')?.focus())
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (import.meta.client) document.body.classList.remove('nav-open')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="site-header">
|
||||
<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" />
|
||||
<List v-else :size="24" />
|
||||
</button>
|
||||
<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()">{{ link.label }}</a>
|
||||
</nav>
|
||||
<a class="header-cta" href="/#official">
|
||||
官方渠道
|
||||
<ArrowRight :size="18" weight="light" />
|
||||
</a>
|
||||
</header>
|
||||
</template>
|
||||
Reference in New Issue
Block a user