feat: 初始化慧遇书院官网一期

This commit is contained in:
2026-08-12 18:01:17 +08:00
commit 5d31ee5743
101 changed files with 17259 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>