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

44
app/app.vue Normal file
View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
const route = useRoute()
const config = useRuntimeConfig()
const scrollProgress = ref(0)
let scrollFrame = 0
const canonical = computed(() => `${String(config.public.siteUrl).replace(/\/$/, '')}${route.path === '/' ? '/' : route.path}`)
const isDraftPreview = computed(() => route.query.preview === 'draft')
useHead(() => route.path.startsWith('/admin') ? {} : {
link: [{ rel: 'canonical', href: canonical.value }],
})
useSeoMeta({ ogUrl: () => canonical.value, twitterCard: 'summary_large_image' })
const updateScrollProgress = () => {
scrollFrame = 0
const max = document.documentElement.scrollHeight - window.innerHeight
scrollProgress.value = max > 0 ? Math.min(1, window.scrollY / max) : 0
}
const requestProgressUpdate = () => {
if (!scrollFrame) scrollFrame = requestAnimationFrame(updateScrollProgress)
}
onMounted(() => {
updateScrollProgress()
window.addEventListener('scroll', requestProgressUpdate, { passive: true })
window.addEventListener('resize', requestProgressUpdate, { passive: true })
})
onBeforeUnmount(() => {
if (import.meta.client) {
window.removeEventListener('scroll', requestProgressUpdate)
window.removeEventListener('resize', requestProgressUpdate)
}
if (scrollFrame) cancelAnimationFrame(scrollFrame)
})
</script>
<template>
<NuxtRouteAnnouncer />
<a class="skip-link" href="#main-content">跳到正文</a>
<div v-if="!route.path.startsWith('/admin')" class="page-progress" aria-hidden="true"><span :style="{ transform: `scaleX(${scrollProgress})` }" /></div>
<div v-if="isDraftPreview" class="draft-preview-ribbon"><strong>草稿预览</strong><span>此页面不会被搜索引擎收录</span><NuxtLink to="/admin">返回后台</NuxtLink></div>
<NuxtPage />
</template>

503
app/assets/css/main.scss Normal file
View File

@@ -0,0 +1,503 @@
:root {
--ink: #0b3152;
--ink-2: #234d70;
--muted: #667d92;
--line: rgba(36, 77, 112, 0.13);
--mist: #eef3f8;
--paper: #fbfcfd;
--warm: #f6eee5;
--white: #fff;
--shadow: 0 22px 70px rgba(42, 75, 104, 0.12);
--shadow-card: 0 16px 44px rgba(42, 75, 104, 0.1);
--section-space: clamp(104px, 8vw, 120px);
--serif: "Songti SC", "STSong", "Noto Serif SC", "Source Han Serif SC", serif;
--sans: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; background: var(--paper); }
body { margin: 0; color: var(--ink); font-family: var(--sans); font-size: 16px; line-height: 1.65; background: var(--paper); -webkit-font-smoothing: antialiased; }
body.nav-open { overflow: hidden; }
a { color: inherit; text-decoration: none; }
button, input, textarea { font: inherit; }
button { color: inherit; }
img { max-width: 100%; }
:focus-visible { outline: 3px solid #2f70a6; outline-offset: 4px; }
.skip-link { position: fixed; z-index: 1000; top: 12px; left: 12px; padding: 10px 14px; border-radius: 9px; color: white; background: var(--ink); transform: translateY(-150%); transition: transform .2s ease; }
.skip-link:focus { transform: none; }
.page-progress { position: fixed; z-index: 100; top: 0; left: 0; width: 100%; height: 3px; pointer-events: none; }
.page-progress span { display: block; width: 100%; height: 100%; background: linear-gradient(90deg, #6f9dc1, #dcae79, #245a83); transform-origin: left; }
.draft-preview-ribbon { position: fixed; z-index: 90; right: 20px; bottom: 20px; display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid rgba(35,77,112,.16); border-radius: 14px; background: rgba(255,255,255,.94); box-shadow: var(--shadow); backdrop-filter: blur(16px); }
.draft-preview-ribbon span { color: var(--muted); font-size: 12px; }
.draft-preview-ribbon a { padding: 7px 10px; border-radius: 8px; color: white; background: var(--ink); font-size: 12px; }
.page-enter-active, .page-leave-active { transition: opacity .28s ease; }
.page-enter-from, .page-leave-to { opacity: 0; }
.eyebrow { margin: 0 0 14px; color: #607b92; font-size: 13px; line-height: 1.4; letter-spacing: .24em; text-transform: uppercase; }
.section-pad { padding: var(--section-space) max(6vw, 32px); }
.section-heading h2 { margin: 0; font: 500 clamp(36px, 4.1vw, 58px)/1.18 var(--serif); letter-spacing: .045em; }
.section-heading > p:last-child { color: var(--muted); font-size: 17px; line-height: 1.9; }
.section-heading.centered { max-width: 780px; margin: 0 auto 56px; text-align: center; }
.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(24px, 3.2vw, 54px); }
.main-nav a { position: relative; display: inline-flex; align-items: center; min-height: 44px; padding: 0 4px; color: #17364e; font-size: 14px; white-space: nowrap; }
.main-nav a::after { position: absolute; right: 50%; bottom: -12px; 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; }
.header-cta::before, .primary-button::before { position: absolute; top: -80%; left: -45%; z-index: -1; width: 32%; height: 260%; content: ""; background: linear-gradient(90deg, transparent, rgba(255,255,255,.32), transparent); transform: rotate(20deg) translateX(-260%); transition: transform .65s cubic-bezier(.2,.7,.2,1); }
.header-cta:hover::before, .header-cta:focus-visible::before, .primary-button:hover::before, .primary-button:focus-visible::before { transform: rotate(20deg) translateX(620%); }
.header-cta { min-height: 46px; padding: 0 22px; font-size: 14px; }
.menu-toggle { display: none; border: 0; background: transparent; }
.hero { --hero-scroll: 0; --pointer-x: 0; --pointer-y: 0; position: relative; min-height: 81svh; overflow: hidden; background: #e8eef3; }
.hero-media { position: absolute; inset: -4%; display: block; }
.hero-media img { width: 100%; height: 100%; max-width: none; object-fit: cover; transform: translate3d(calc(var(--pointer-x) * -12px), calc(var(--hero-scroll) * 36px + var(--pointer-y) * -8px), 0) scale(1.035); transition: transform .18s linear; will-change: transform; }
.hero::after { position: absolute; inset: 0; z-index: 0; content: ""; pointer-events: none; background: linear-gradient(180deg, rgba(255,255,255,.03), transparent 65%, rgba(255,255,255,.28)); }
.hero-copy { position: relative; z-index: 2; width: min(720px, 72vw); padding: clamp(230px, 29vh, 280px) 0 120px 10.5vw; transform: translate3d(0, calc(var(--hero-scroll) * -38px), 0); animation: hero-in 1s cubic-bezier(.2,.7,.2,1) both; will-change: transform; }
.hero-copy h1 { display: flex; flex-direction: column; margin: 0 0 24px; font: 500 clamp(58px, 4.5vw, 74px)/1.08 var(--serif); letter-spacing: .055em; text-shadow: 0 2px 24px rgba(255,255,255,.36); }
.hero-copy h1 span { display: block; animation: title-rise .9s cubic-bezier(.16,.8,.25,1) both; }
.hero-copy h1 span:nth-child(2) { animation-delay: .12s; }
.hero-subtitle { margin: 0 0 38px; color: #244b69; font: 400 clamp(18px, 1.45vw, 24px)/1.75 var(--serif); letter-spacing: .085em; }
.primary-button { min-height: 52px; padding: 0 28px; transition: transform .25s ease, box-shadow .25s ease; }
.primary-button:hover { transform: translateY(-3px); box-shadow: 0 18px 34px rgba(10,52,86,.26); }
.scroll-cue { position: absolute; z-index: 3; bottom: 54px; left: 50%; display: flex; flex-direction: column; align-items: center; gap: 8px; color: rgba(12,49,80,.68); font-size: 11px; letter-spacing: .18em; transform: translateX(-50%); animation: float-cue 2.2s ease-in-out infinite; }
.hero-wave { position: absolute; z-index: 1; right: -5%; bottom: -82px; left: -5%; height: 142px; border-radius: 50% 50% 0 0/100% 100% 0 0; background: var(--paper); }
.reveal-section { opacity: .68; transform: translateY(24px); transition: opacity .65s ease, transform .65s cubic-bezier(.16,.8,.25,1); }
.reveal-section.visible { opacity: 1; transform: none; }
.reveal-section > * { opacity: .72; transform: translateY(16px); }
.reveal-section.visible > * { animation: section-rise .68s cubic-bezier(.16,.8,.25,1) both; }
.reveal-section.visible > *:nth-child(2) { animation-delay: .07s; }
.reveal-section.visible > *:nth-child(3) { animation-delay: .12s; }
.about.reveal-section { opacity: 1; transform: none; }
.about.reveal-section > * { opacity: 1; transform: none; }
.about { min-height: 100svh; padding-top: 54px; background: var(--paper); }
.about-layout { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(320px, .75fr); gap: 44px; max-width: 1260px; margin: 0 auto; }
.about-visual { min-height: 430px; overflow: hidden; border-radius: 34px; box-shadow: var(--shadow); }
.about-visual img { width: 100%; height: 100%; object-fit: cover; transform: scale(1.1); transition: transform 1.8s cubic-bezier(.16,.8,.25,1); }
.reveal-section.visible .about-visual img { transform: scale(1.025); }
.metric-grid { display: grid; grid-template-rows: repeat(3, 1fr); gap: 16px; }
.metric-card { display: grid; grid-template-columns: 52px 104px minmax(0, 1fr); grid-template-rows: 1fr 1fr; column-gap: 14px; align-items: center; padding: 24px 28px; border: 1px solid rgba(68,105,133,.11); border-radius: 24px; opacity: .72; background: rgba(239,244,249,.8); box-shadow: 0 14px 34px rgba(62,91,116,.1); transform: translateX(18px); transition: transform .35s ease, box-shadow .35s ease, border-color .35s ease; }
.reveal-section.visible .metric-card { animation: metric-in .75s cubic-bezier(.16,.8,.25,1) both; animation-delay: var(--delay); }
.metric-card:hover { transform: translateX(5px); }
.metric-card svg { grid-row: 1/3; align-self: center; color: #6689a7; }
.metric-card strong { grid-column: 2; grid-row: 1/3; align-self: center; white-space: nowrap; font: 500 27px/1.2 var(--serif); }
.metric-card span { grid-column: 3; grid-row: 1; align-self: end; font-size: 15px; line-height: 1.45; }
.metric-card small { grid-column: 3; grid-row: 2; align-self: start; margin-top: 5px; color: var(--muted); line-height: 1.45; }
.teacher-section { min-height: 100svh; background: linear-gradient(135deg, #edf3f8, #fff9f4); }
.teacher-card { display: grid; grid-template-columns: minmax(360px, .86fr) minmax(420px, 1.14fr); max-width: 1270px; min-height: 650px; margin: 0 auto; overflow: hidden; border: 1px solid rgba(255,255,255,.8); border-radius: 38px; background: rgba(255,255,255,.72); box-shadow: var(--shadow); clip-path: inset(0 100% 0 0 round 38px); }
.reveal-section.visible .teacher-card { animation: panel-wipe .72s cubic-bezier(.16,.8,.25,1) both; }
.teacher-portrait { position: relative; overflow: hidden; background: linear-gradient(180deg, #dfe9f1, #f8f1eb); }
.teacher-portrait::after { position: absolute; inset: 8% -30% auto 4%; height: 82%; border: 1px solid rgba(255,255,255,.7); border-radius: 50%; content: ""; }
.teacher-portrait img { position: relative; z-index: 1; width: 100%; height: 100%; object-fit: cover; object-position: center 18%; transform: scale(1.06); transition: transform 1.2s cubic-bezier(.16,.8,.25,1); }
.teacher-card:hover .teacher-portrait img { transform: scale(1.015); }
.teacher-copy { align-self: center; max-width: 680px; padding: 70px clamp(44px, 5vw, 82px); }
.teacher-copy h2 { margin: 0 0 12px; font: 500 clamp(44px, 4.1vw, 58px)/1.18 var(--serif); letter-spacing: .045em; }
.teacher-title { color: #365a77; font: 400 18px/1.7 var(--serif); }
.teacher-summary { max-width: 610px; margin: 26px 0 30px; color: #526e84; font-size: 16px; line-height: 1.95; }
.tag-list { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 34px; }
.tag-list span { padding: 9px 15px; border: 1px solid var(--line); border-radius: 999px; color: #456984; background: rgba(238,244,248,.8); font-size: 13px; }
.text-link { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 5px 2px; border-bottom: 1px solid #7892a7; transition: gap .25s ease, color .25s ease; }
.text-link:hover, .text-link:focus-visible { gap: 13px; color: #1d5d8e; }
.philosophy { min-height: 90svh; }
.philosophy-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; max-width: 1260px; margin: 0 auto; }
.philosophy-card { position: relative; min-height: 360px; padding: 46px; overflow: hidden; border-radius: 30px; background-image: linear-gradient(180deg, rgba(246,249,251,.38), rgba(229,237,245,.88)), url('/images/hero-bg-v3.webp'); background-position: center; background-size: 118%; box-shadow: 0 18px 46px rgba(54,84,110,.12); transition: transform .5s cubic-bezier(.16,.8,.25,1), background-size .8s ease, box-shadow .5s ease; }
.philosophy-card:nth-child(2) { background-position: 68% center; }
.philosophy-card:nth-child(3) { background-position: 20% center; }
.philosophy-card:hover { transform: translateY(-10px) rotateX(2deg); background-size: 108%; box-shadow: 0 28px 60px rgba(54,84,110,.18); }
.philosophy-index { position: absolute; top: 34px; right: 38px; color: rgba(11,49,82,.38); font: 500 13px var(--sans); letter-spacing: .18em; }
.philosophy-card h3 { margin: 90px 0 8px; font: 500 42px var(--serif); }
.philosophy-card p { color: #526f86; }
.products { background: linear-gradient(180deg, #f8fafc, #eef3f7); }
.split-heading { display: flex; align-items: end; justify-content: space-between; max-width: 1260px; margin: 0 auto 56px; }
.split-heading > p { max-width: 470px; margin: 0; }
.product-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; max-width: 1260px; margin: 0 auto; }
.product-card { display: grid; grid-template-columns: 1fr auto; overflow: hidden; border: 1px solid rgba(36,77,112,.06); border-radius: 26px; background: white; box-shadow: var(--shadow-card); transition: transform .38s cubic-bezier(.16,.8,.25,1), box-shadow .38s ease; }
.product-card:hover { transform: translateY(-9px); box-shadow: 0 28px 56px rgba(52,82,107,.16); }
.product-card img { grid-column: 1/3; width: 100%; height: 240px; object-fit: cover; transition: transform .75s cubic-bezier(.16,.8,.25,1); }
.product-card:hover img { transform: scale(1.045); }
.product-card div { padding: 25px 8px 28px 28px; }
.product-card h3 { margin: 0 0 8px; font: 500 24px var(--serif); }
.product-card p { margin: 0; color: var(--muted); }
.product-card > svg { align-self: center; margin-right: 25px; }
.official { min-height: 76svh; }
.official-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; max-width: 1260px; margin: 0 auto; }
.official-grid article { min-height: 230px; padding: 36px; border: 1px solid var(--line); border-radius: 26px; background: white; transition: transform .35s ease, border-color .35s ease, box-shadow .35s ease; }
.official-grid article:hover { border-color: rgba(36,77,112,.24); box-shadow: 0 20px 42px rgba(54,84,110,.1); transform: translateY(-6px); }
.official-grid article svg { color: #6e8faa; }
.official-grid h3 { margin: 46px 0 10px; font: 500 23px var(--serif); }
.official-grid p { color: var(--muted); font-size: 14px; }
.news-section { background: #eef3f7; }
.news-panel { display: grid; grid-template-columns: .9fr 1.1fr; max-width: 1260px; margin: 0 auto; overflow: hidden; border-radius: 34px; background: white; box-shadow: var(--shadow); }
.news-feature { min-height: 430px; padding: 72px 56px; background-image: linear-gradient(120deg, rgba(238,244,249,.9), rgba(255,249,244,.72)), url('/images/hero-bg-v3.webp'); background-size: cover; }
.news-feature h2 { margin: 0 0 24px; font: 500 48px var(--serif); }
.news-feature > p:last-child { max-width: 420px; color: var(--muted); line-height: 1.9; }
.news-list { display: flex; flex-direction: column; justify-content: center; padding: 38px 54px; }
.news-list a { display: grid; grid-template-columns: 1fr auto 20px; gap: 20px; align-items: center; padding: 24px 0; border-bottom: 1px solid var(--line); }
.news-list time { color: #8092a1; font-size: 12px; }
.site-footer { display: grid; grid-template-columns: 1fr 1fr; gap: 50px; padding: 72px max(6vw, 32px) 26px; background: #e3ebf2; }
.footer-brand p { max-width: 360px; color: var(--muted); line-height: 1.8; }
.footer-contact { display: grid; align-content: center; justify-content: end; }
.footer-contact p { display: flex; align-items: center; gap: 10px; margin: 7px 0; color: #48667e; }
.footer-meta { grid-column: 1/3; display: flex; justify-content: space-between; padding-top: 24px; border-top: 1px solid rgba(44,75,101,.13); color: #667f93; font-size: 13px; }
.footer-meta a { display: inline-flex; align-items: center; min-height: 40px; }
.filing-links { display: inline-flex; align-items: center; gap: 6px; }
.police-filing-link { gap: 5px; }
.disabled-channel { padding: 12px 18px; border: 1px dashed var(--line); border-radius: 999px; color: #7c91a3; cursor: not-allowed; }
.public-page-shell { min-height: 100svh; background: linear-gradient(180deg, #edf3f8 0, #fbfcfd 420px); }
.public-page-shell > .site-header { position: absolute; }
.public-page-main { min-height: 72svh; padding-top: 112px; }
.listing-hero { max-width: 1260px; margin: 0 auto; padding: 100px 32px 74px; text-align: center; }
.listing-hero h1, .detail-hero h1, .story-page h1, .legal-page h1 { margin: 0 0 20px; font: 500 clamp(48px, 5.2vw, 78px)/1.18 var(--serif); letter-spacing: .07em; }
.listing-hero > p:last-child { max-width: 620px; margin: 0 auto; color: var(--muted); font-size: 17px; line-height: 1.9; }
.listing-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; max-width: 1260px; margin: 0 auto; padding: 0 32px 120px; }
.listing-card { overflow: hidden; border-radius: 28px; background: white; box-shadow: var(--shadow); transition: transform .3s ease; }
.listing-card:hover { transform: translateY(-7px); }
.listing-card img { width: 100%; height: 250px; object-fit: cover; }
.listing-card > div { padding: 30px; }
.listing-card small, .article-list time { color: #7890a4; font-size: 12px; letter-spacing: .08em; }
.listing-card h2 { margin: 12px 0; font: 500 27px var(--serif); }
.listing-card p { min-height: 54px; color: var(--muted); line-height: 1.8; }
.listing-card span { display: inline-block; margin-top: 16px; color: #2e638f; font-size: 13px; }
.article-list, .statement-list { max-width: 1080px; margin: 0 auto; padding: 0 32px 120px; }
.article-list > a { display: grid; grid-template-columns: 230px 1fr 30px; gap: 30px; align-items: center; padding: 25px 0; border-bottom: 1px solid var(--line); }
.article-list img { width: 230px; height: 140px; border-radius: 20px; object-fit: cover; }
.article-list h2, .statement-list h2 { margin: 9px 0; font: 500 26px var(--serif); }
.article-list p, .statement-list p { margin: 0; color: var(--muted); line-height: 1.8; }
.statement-list > a { display: flex; align-items: center; justify-content: space-between; gap: 30px; padding: 34px; border-bottom: 1px solid var(--line); }
.statement-list small, .statement-list time { color: #7c91a3; }
.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; }
.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); }
.related-content a { display: flex; justify-content: space-between; gap: 24px; padding: 16px 0; border-bottom: 1px solid var(--line); }
.related-content time { color: #7b91a4; font-size: 12px; }
.story-body .lead { margin-bottom: 30px; color: #315a7a; font: 400 22px/1.8 var(--serif); }
.long-copy { color: #516b80; font-size: 16px; line-height: 2.15; white-space: pre-line; }
.story-body .primary-button { min-height: 50px; margin-top: 38px; padding: 0 28px; }
.statement-page { max-width: 900px; }
.statement-page header { padding: 66px 30px; border-radius: 34px; background: rgba(255,255,255,.7); box-shadow: var(--shadow); }
.detail-hero { display: grid; grid-template-columns: 1.05fr .95fr; align-items: center; max-width: 1260px; min-height: 620px; margin: 0 auto; padding: 54px 32px 80px; }
.detail-hero > div { padding-left: 5vw; }
.detail-hero > div > p:last-child { max-width: 540px; color: #4d6d87; font: 400 19px/1.8 var(--serif); }
.detail-hero img { justify-self: center; width: min(440px, 100%); height: 540px; border-radius: 220px 220px 30px 30px; object-fit: cover; object-position: top; box-shadow: var(--shadow); }
.detail-content { display: grid; grid-template-columns: 1.4fr .6fr; gap: 80px; max-width: 1120px; margin: 0 auto; padding: 90px 32px; }
.detail-content h2 { font: 500 42px var(--serif); }
.detail-content aside { padding: 34px; border-radius: 26px; background: #edf3f8; }
.detail-content aside h3 { margin: 0 0 20px; font: 500 20px var(--serif); }
.detail-content aside h3:not(:first-child) { margin-top: 34px; }
.detail-content li { margin: 12px 0; color: var(--muted); line-height: 1.7; }
.timeline-section { background: #eef3f7; }
.timeline-list { max-width: 880px; margin: 0 auto; }
.timeline-list article { display: grid; grid-template-columns: 110px 1fr; gap: 28px; padding: 27px 0; border-bottom: 1px solid var(--line); }
.timeline-list time { color: #4f7797; font-size: 22px; }
.timeline-list h3 { margin: 0 0 8px; font: 500 22px var(--serif); }
.timeline-list p { margin: 0; color: var(--muted); }
.authority-links { display: flex; align-items: center; justify-content: center; gap: 22px; padding: 70px 30px; }
.authority-links h2 { margin-right: 30px; font: 500 28px var(--serif); }
.authority-links a { padding: 12px 18px; border: 1px solid var(--line); border-radius: 999px; transition: transform .25s ease, border-color .25s ease, background .25s ease; }
.authority-links a:hover { border-color: #7899b2; background: #f2f6fa; transform: translateY(-2px); }
.team-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; max-width: 1100px; margin: 0 auto; padding: 0 32px 120px; }
.team-grid article { display: grid; grid-template-columns: 210px 1fr; gap: 30px; padding: 24px; border-radius: 28px; background: white; box-shadow: var(--shadow); }
.team-grid img { width: 210px; height: 290px; border-radius: 20px; object-fit: cover; object-position: top; }
.team-grid article > div { min-width: 0; }
.team-grid h2, .team-grid h3 { margin: 18px 0 8px; font: 500 28px var(--serif); }
.team-grid h2, .team-grid h3, .team-grid .role { word-break: keep-all; overflow-wrap: normal; }
.team-grid .role { color: #426987; }
.team-grid article > div > p:not(.role) { color: var(--muted); line-height: 1.8; }
.legal-page { max-width: 850px; margin: 0 auto; padding: 110px 32px 160px; }
.login-page { display: grid; grid-template-columns: 1.05fr .95fr; min-height: 100svh; padding: 3.8vw; background: url('/images/hero-bg-v3.webp') center/cover; }
.login-brand { display: flex; flex-direction: column; justify-content: space-between; padding: 8px 5vw 7vh 0; }
.login-brand h1 { margin: 0 0 22px; font: 500 clamp(58px, 5vw, 86px)/1.15 var(--serif); letter-spacing: .08em; }
.login-brand p { color: #4d6d87; font: 400 20px var(--serif); letter-spacing: .08em; }
.login-card { align-self: center; width: min(620px, 100%); padding: clamp(48px, 5vw, 78px); border: 1px solid rgba(255,255,255,.78); border-radius: 34px; background: rgba(255,255,255,.76); box-shadow: 0 28px 80px rgba(58,76,91,.17); backdrop-filter: blur(22px); }
.login-card h2 { margin: 0; font: 500 43px var(--serif); letter-spacing: .08em; }
.login-intro { margin: 14px 0 40px; color: var(--muted); }
.login-card form { display: grid; }
.login-card label { margin: 18px 0 9px; font-weight: 600; }
.field { display: flex; align-items: center; gap: 12px; height: 58px; padding: 0 17px; border: 1px solid #cbd7e1; border-radius: 13px; background: rgba(255,255,255,.78); }
.field:focus-within { border-color: #5d8fbb; box-shadow: 0 0 0 3px rgba(74,127,172,.1); }
.field svg { color: #6d87a0; flex: none; }
.field input { width: 100%; border: 0; outline: 0; background: transparent; }
.field button { display: inline-flex; border: 0; background: transparent; cursor: pointer; }
.form-error { margin: 14px 0 0; color: #a94b43; font-size: 13px; }
.login-submit { display: flex; align-items: center; justify-content: center; gap: 14px; height: 58px; margin-top: 30px; border: 0; border-radius: 13px; color: white; background: linear-gradient(135deg, #326992, #0b3557); box-shadow: 0 14px 28px rgba(19,58,91,.2); cursor: pointer; }
.login-submit:disabled { opacity: .66; cursor: wait; }
.security-note { margin: 28px 0 0; color: #607a90; font-size: 13px; text-align: center; }
.admin-shell { display: grid; grid-template-columns: 232px 1fr; min-height: 100svh; background: #f3f6f9; }
.admin-sidebar { position: sticky; top: 0; display: flex; flex-direction: column; height: 100svh; padding: 24px 18px; border-right: 1px solid var(--line); background: linear-gradient(180deg, #f9fbfd 60%, #f5eee7); }
.admin-sidebar nav { display: grid; gap: 8px; margin-top: 48px; }
.admin-sidebar nav a, .admin-sidebar > button { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 0 14px; border: 0; border-radius: 12px; background: transparent; font-size: 14px; }
.admin-sidebar nav a.active { color: #1f5a8b; background: #e3edf9; }
.admin-sidebar > button { margin-top: auto; cursor: pointer; }
.admin-main { min-width: 0; }
.admin-topbar { position: sticky; z-index: 10; top: 0; display: flex; align-items: center; justify-content: space-between; min-height: 88px; padding: 16px 28px; border-bottom: 1px solid var(--line); background: rgba(249,251,253,.9); backdrop-filter: blur(18px); }
.admin-topbar p { margin: 0 0 5px; font: 500 22px var(--serif); }
.admin-topbar small { color: var(--muted); }
.admin-actions { display: flex; align-items: center; gap: 10px; }
.admin-actions a, .admin-actions button { display: inline-flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 15px; border: 1px solid #ccd8e2; border-radius: 9px; background: white; cursor: pointer; }
.admin-actions .publish-button { border-color: var(--ink); color: white; background: var(--ink); }
.save-message { display: inline-flex; align-items: center; gap: 6px; margin-right: 4px; color: #3f8557; font-size: 13px; }
.dirty-indicator { padding: 7px 10px; border-radius: 999px; color: #8b5d2b; background: #fff1df; font-size: 12px; }
.admin-loading { padding: 90px; color: var(--muted); text-align: center; }
.admin-workspace { display: grid; grid-template-columns: minmax(540px, 1fr) minmax(430px, .9fr); gap: 20px; padding: 22px; }
.editor-panel, .preview-panel { border: 1px solid rgba(29,64,91,.08); border-radius: 20px; background: white; box-shadow: 0 12px 32px rgba(44,72,96,.06); }
.editor-panel { padding: 28px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; padding-bottom: 18px; border-bottom: 1px solid var(--line); }
.panel-heading.secondary { margin-top: 42px; }
.panel-heading > div { display: flex; align-items: center; gap: 12px; }
.panel-heading span { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 10px; color: #3d6e97; background: #e6f0fa; font-size: 12px; }
.panel-heading h1, .panel-heading h2 { margin: 0; font: 500 20px var(--serif); }
.panel-heading small { color: var(--muted); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding-top: 22px; }
.form-grid label { display: grid; gap: 8px; color: #38566e; font-size: 13px; }
.form-grid label.full { grid-column: 1/3; }
.form-grid input, .form-grid textarea { width: 100%; padding: 12px 13px; border: 1px solid #d5dfe7; border-radius: 9px; outline: 0; color: #1c3c56; background: #fbfcfd; resize: vertical; }
.form-grid input:focus, .form-grid textarea:focus { border-color: #6d96b7; box-shadow: 0 0 0 3px rgba(78,128,168,.08); }
.image-input { position: relative; display: grid; grid-template-columns: 116px 1fr; align-items: center; min-height: 82px; overflow: hidden; border: 1px dashed #b8c8d6; border-radius: 12px; background: #f8fafc; cursor: pointer; }
.image-input img { width: 116px; height: 82px; padding: 8px; object-fit: contain; background: white; }
.image-input.portrait img { object-fit: cover; object-position: top; }
.image-input input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.image-input b { justify-self: center; color: #3d6e97; font-size: 13px; font-weight: 600; }
.media-placeholder { display: flex; align-items: center; gap: 18px; margin-top: 38px; padding: 24px; border: 1px dashed #b8c8d6; border-radius: 14px; color: #53738d; background: #f8fafc; }
.media-placeholder strong { display: block; margin-bottom: 5px; }
.media-placeholder p { margin: 0; color: var(--muted); font-size: 13px; }
.preview-panel { position: sticky; top: 109px; align-self: start; padding: 18px; }
.preview-label { display: flex; justify-content: space-between; padding: 4px 4px 16px; font-size: 14px; }
.preview-label small { color: var(--muted); }
.preview-window { min-height: 390px; padding: 22px; overflow: hidden; border-radius: 16px; background-position: center; background-size: cover; }
.preview-window .brand-logo img { width: 38px; height: 38px; }
.preview-window .brand-logo-copy strong { font-size: 17px; }
.preview-window .brand-logo-copy small { font-size: 6px; }
.preview-window > div:last-child { margin: 82px 0 0 8%; }
.preview-window h2 { margin: 0 0 12px; font: 500 44px/1.17 var(--serif); }
.preview-window p { max-width: 330px; color: #496b85; font: 400 14px var(--serif); }
.preview-window span { display: inline-flex; margin-top: 16px; padding: 9px 15px; border-radius: 999px; color: white; background: var(--ink); font-size: 11px; }
.preview-teacher { display: grid; grid-template-columns: 128px 1fr; gap: 22px; margin-top: 16px; padding: 22px; border-radius: 15px; background: linear-gradient(135deg, #eef4f8, #fff8f2); }
.preview-teacher img { width: 128px; height: 168px; border-radius: 12px; object-fit: cover; object-position: top; }
.preview-teacher h3 { margin: 7px 0; font: 500 25px var(--serif); }
.preview-teacher p { margin: 0 0 12px; color: #496b85; font-size: 12px; }
.preview-teacher small { display: block; color: var(--muted); line-height: 1.7; }
.content-admin-body, .settings-admin-body { padding: 22px; }
.content-tabs { display: flex; gap: 8px; margin-bottom: 16px; padding: 6px; width: fit-content; border: 1px solid var(--line); border-radius: 12px; background: white; }
.content-tabs button { padding: 10px 18px; border: 0; border-radius: 8px; color: var(--muted); background: transparent; cursor: pointer; }
.content-tabs button.active { color: white; background: var(--ink); }
.content-layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 18px; }
.content-list, .content-editor { border: 1px solid rgba(29,64,91,.08); border-radius: 18px; background: white; box-shadow: 0 12px 32px rgba(44,72,96,.05); }
.content-list { display: flex; flex-direction: column; gap: 7px; align-self: start; padding: 12px; }
.content-list > button { display: grid; gap: 5px; padding: 13px; border: 0; border-radius: 10px; text-align: left; background: transparent; cursor: pointer; }
.content-list > button.active { color: #1f5a8b; background: #e8f0f8; }
.content-list > button strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.content-list > button small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.content-list .add-content { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 5px; border: 1px dashed #aac0d2; color: #35698f; }
.content-editor { padding: 28px; }
.danger-link, .inline-add { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 0; border-radius: 8px; color: #9b4b43; background: #fff1ef; cursor: pointer; }
.inline-add { color: #35698f; background: #e9f1f8; }
.form-grid select { width: 100%; padding: 12px 13px; border: 1px solid #d5dfe7; border-radius: 9px; color: #1c3c56; background: #fbfcfd; }
.compact-upload { position: relative; justify-self: start; margin-top: 2px; padding: 7px 11px; overflow: hidden; border: 1px solid #ccd8e2; border-radius: 8px; cursor: pointer; }
.compact-upload input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.media-picker-trigger { display: inline-flex; align-items: center; justify-self: start; gap: 7px; margin-top: 3px; padding: 8px 11px; border: 1px solid #ccd8e2; border-radius: 8px; color: #35698f; background: white; cursor: pointer; }
.media-picker-backdrop { position: fixed; z-index: 1000; inset: 0; display: grid; padding: 28px; place-items: center; background: rgba(15,35,52,.34); backdrop-filter: blur(8px); }
.media-picker-dialog { display: grid; grid-template-rows: auto auto minmax(0,1fr); width: min(900px, 96vw); max-height: min(760px, 90vh); padding: 24px; border-radius: 24px; background: white; box-shadow: 0 34px 100px rgba(17,44,66,.28); }
.media-picker-dialog > header { display: flex; align-items: center; justify-content: space-between; }
.media-picker-dialog header small { color: #7890a3; letter-spacing: .18em; }
.media-picker-dialog h2 { margin: 5px 0 16px; font: 500 30px var(--serif); }
.media-picker-dialog header button { display: grid; width: 40px; height: 40px; place-items: center; border: 0; border-radius: 50%; background: #eef3f7; cursor: pointer; }
.media-picker-search { display: flex; align-items: center; gap: 10px; padding: 0 14px; border: 1px solid #d5dfe7; border-radius: 11px; }
.media-picker-search input { width: 100%; height: 44px; border: 0; outline: 0; }
.media-picker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px,1fr)); gap: 12px; margin-top: 18px; padding-right: 4px; overflow: auto; }
.media-picker-grid button { position: relative; display: grid; gap: 7px; min-width: 0; padding: 8px; border: 1px solid transparent; border-radius: 14px; text-align: left; background: #f5f8fa; cursor: pointer; }
.media-picker-grid button:hover, .media-picker-grid button.selected { border-color: #7396b3; background: #edf4f9; }
.media-picker-grid img { width: 100%; aspect-ratio: 4/3; border-radius: 10px; object-fit: cover; }
.media-picker-grid span, .media-picker-grid small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.media-picker-grid small { color: #7890a3; }
.media-picker-grid svg { position: absolute; top: 14px; right: 14px; padding: 4px; border-radius: 50%; color: white; background: #2e648e; box-sizing: content-box; }
.media-picker-empty { display: grid; min-height: 260px; place-items: center; color: var(--muted); }
.empty-editor { padding: 100px 20px; color: var(--muted); text-align: center; }
.settings-admin-body { display: grid; gap: 18px; max-width: 1080px; }
.subsection-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 30px; }
.subsection-heading h3 { margin: 0; font: 500 17px var(--serif); }
.repeat-settings article { position: relative; margin-top: 18px; padding: 0 18px 18px; border: 1px solid var(--line); border-radius: 14px; background: #fafcfd; }
.repeat-settings article > .danger-link { margin-top: 14px; }
.health-ok { color: #317346 !important; }
.audit-list { display: grid; padding-top: 10px; }
.audit-list article { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 13px 3px; border-bottom: 1px solid var(--line); }
.audit-list article > div { display: flex; align-items: center; gap: 12px; }
.audit-list article span, .audit-list article small { color: var(--muted); font-size: 12px; }
.centered-links { justify-content: center; margin-top: 42px; }
.authority-links a.disabled { pointer-events: none; opacity: .62; }
.compact-team { max-width: 1180px; margin: 0 auto; }
.center-action { display: flex; justify-content: center; margin-top: 34px; }
.media-admin-body { display: grid; gap: 18px; padding: 22px; }
.media-upload-panel { display: grid; grid-template-columns: 240px minmax(0, 1fr) 180px; align-items: center; gap: 18px; padding: 22px; }
.media-upload-panel > div:first-child { display: grid; grid-template-columns: 38px 1fr; align-items: center; color: #35698f; }
.media-upload-panel > div:first-child small { grid-column: 2; margin-top: 3px; color: var(--muted); }
.media-upload-meta { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.media-upload-meta input, .media-upload-meta select { min-width: 0; padding: 10px 11px; border: 1px solid #d5dfe7; border-radius: 8px; background: #fbfcfd; }
.media-upload-button { position: relative; display: grid; min-height: 44px; place-items: center; overflow: hidden; border-radius: 9px; color: white; background: var(--ink); cursor: pointer; }
.media-upload-button input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.media-library-layout { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 18px; align-items: start; }
.media-grid-panel { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; min-height: 360px; padding: 14px; border: 1px solid rgba(29,64,91,.08); border-radius: 18px; background: white; }
.media-tile { position: relative; display: grid; align-content: start; gap: 6px; min-width: 0; padding: 8px; border: 1px solid transparent; border-radius: 13px; text-align: left; background: #f7f9fb; cursor: pointer; }
.media-tile.active { border-color: #6d96b7; background: #eef4f9; box-shadow: 0 0 0 3px rgba(78,128,168,.08); }
.media-tile img { width: 100%; aspect-ratio: 4/3; border-radius: 9px; object-fit: cover; background: #e9eef2; }
.media-tile span, .media-tile small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.media-tile small { color: var(--muted); }
.media-tile b { position: absolute; top: 14px; right: 14px; padding: 4px 7px; border-radius: 999px; color: #2f7448; background: #e5f4e9; font-size: 10px; }
.media-detail { position: sticky; top: 108px; padding: 18px; }
.media-detail > img { width: 100%; max-height: 300px; border-radius: 12px; object-fit: contain; background: #edf1f4; }
.media-detail-meta { display: grid; gap: 5px; padding: 14px 2px 0; }
.media-detail-meta small { color: var(--muted); }
.media-detail-meta a { justify-self: start; color: #35698f; font-size: 12px; }
.media-detail-actions { display: flex; gap: 8px; margin-top: 18px; }
.media-detail-actions button { display: inline-flex; align-items: center; gap: 7px; padding: 10px 13px; border: 1px solid #ccd8e2; border-radius: 9px; background: white; cursor: pointer; }
.revision-list { display: grid; gap: 8px; padding-top: 20px; }
.revision-list article { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 14px; border: 1px solid var(--line); border-radius: 10px; background: #fafcfd; }
.revision-list article > div { display: flex; align-items: center; gap: 16px; }
.revision-list small { color: var(--muted); }
.revision-list button { padding: 8px 11px; border: 1px solid #ccd8e2; border-radius: 8px; color: #35698f; background: white; cursor: pointer; }
.password-action { display: flex; align-items: end; }
@keyframes hero-in { from { opacity: 0; transform: translateY(26px); filter: blur(6px); } to { opacity: 1; transform: translateY(calc(var(--hero-scroll) * -38px)); filter: none; } }
@keyframes title-rise { from { opacity: 0; transform: translateY(34px); } to { opacity: 1; transform: none; } }
@keyframes section-rise { from { opacity: 0; transform: translateY(28px); } to { opacity: 1; transform: none; } }
@keyframes metric-in { from { opacity: 0; transform: translateX(26px); } to { opacity: 1; transform: none; } }
@keyframes panel-wipe { from { opacity: .4; clip-path: inset(0 100% 0 0 round 38px); transform: translateY(22px); } to { opacity: 1; clip-path: inset(0 0 0 0 round 38px); transform: none; } }
@keyframes float-cue { 0%,100% { transform: translate(-50%, 0); } 50% { transform: translate(-50%, 7px); } }
@media (max-width: 1100px) {
.site-header { position: fixed; grid-template-columns: 1fr auto; min-height: 72px; padding: 12px 24px; border-bottom: 1px solid rgba(28,62,88,.08); background: rgba(248,250,252,.9); backdrop-filter: blur(18px); }
.menu-toggle { z-index: 3; display: inline-flex; padding: 10px; cursor: pointer; }
.main-nav { position: fixed; top: 0; left: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100vw; height: 100svh; gap: 28px; 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: 180px; min-height: 48px; justify-content: center; font: 500 24px var(--serif); }
.header-cta { display: none; }
.teacher-card { grid-template-columns: .9fr 1.1fr; }
.official-grid { grid-template-columns: repeat(2, 1fr); }
.team-grid { grid-template-columns: 1fr; max-width: 760px; }
.admin-workspace { grid-template-columns: 1fr; }
.preview-panel { position: static; }
}
@media (max-width: 760px) {
:root { --section-space: 68px; }
.section-pad { padding: var(--section-space) 20px; }
.site-header { min-height: 70px; padding: 11px 18px; }
.section-heading.centered { margin-bottom: 40px; }
.section-heading > p:last-child { font-size: 16px; line-height: 1.82; }
.eyebrow { font-size: 12px; letter-spacing: .22em; }
.hero-copy { width: 100%; padding: 25vh 22px 130px; text-align: center; }
.hero-copy h1 { font-size: clamp(50px, 14.2vw, 56px); letter-spacing: .045em; }
.hero-subtitle { margin-bottom: 30px; font-size: 17px; line-height: 1.65; letter-spacing: .06em; }
.hero { min-height: 100svh; background-position: 68% center; }
.hero-wave { bottom: -105px; }
.about-layout, .teacher-card, .news-panel, .login-page { grid-template-columns: 1fr; }
.listing-grid, .team-grid { grid-template-columns: 1fr; }
.public-page-main { padding-top: 70px; }
.listing-hero { padding-top: 80px; }
.article-list > a { grid-template-columns: 1fr 20px; }
.article-list img { grid-column: 1/3; width: 100%; height: 210px; }
.detail-hero, .detail-content { grid-template-columns: 1fr; }
.detail-hero { padding-top: 70px; text-align: center; }
.detail-hero > div { padding-left: 0; }
.detail-hero img { height: 460px; }
.detail-content { gap: 28px; padding-top: 45px; }
.team-grid article { grid-template-columns: 120px 1fr; }
.team-grid img { width: 120px; height: 175px; }
.authority-links { flex-wrap: wrap; }
.authority-links h2 { width: 100%; margin: 0; text-align: center; }
.about { min-height: 0; padding-top: 48px; }
.about-layout { gap: 28px; }
.about-visual { min-height: 270px; border-radius: 28px; }
.metric-grid { gap: 12px; }
.metric-card { grid-template-columns: 38px 90px minmax(0, 1fr); column-gap: 8px; min-height: 104px; padding: 17px 18px; border-radius: 21px; }
.metric-card strong { font-size: 25px; }
.metric-card span { font-size: 14px; }
.metric-card small { font-size: 13px; }
.teacher-card { min-height: 0; }
.teacher-portrait { height: 470px; }
.teacher-copy { padding: 38px 28px 46px; }
.teacher-copy h2 { font-size: 42px; }
.teacher-title { font-size: 17px; }
.philosophy-grid, .product-grid { grid-template-columns: 1fr; }
.philosophy-grid, .product-grid { gap: 18px; }
.philosophy-card { min-height: 250px; padding: 34px; }
.philosophy-card h3 { margin-top: 70px; font-size: 36px; }
.product-card img { height: 190px; }
.product-card div { padding: 21px 8px 23px 23px; }
.product-card h3 { font-size: 22px; }
.split-heading { display: block; }
.split-heading > p { margin-top: 22px; }
.official-grid { grid-template-columns: 1fr 1fr; }
.official-grid { gap: 12px; }
.official-grid article { min-height: 165px; padding: 22px 18px; border-radius: 22px; }
.official-grid h3 { margin: 24px 0 7px; font-size: 20px; }
.authority-links { gap: 12px; padding: 48px 0 0; }
.authority-links h2 { font-size: 26px; }
.news-feature { min-height: 300px; padding: 42px 28px; }
.news-list { padding: 20px 28px 36px; }
.news-list a { grid-template-columns: 1fr 18px; }
.news-list time { display: none; }
.site-footer { grid-template-columns: 1fr; gap: 32px; padding: 52px 20px 22px; }
.footer-contact { justify-content: start; }
.footer-meta { grid-column: 1; flex-direction: column; gap: 8px; }
.draft-preview-ribbon { right: 12px; bottom: 12px; left: 12px; justify-content: space-between; }
.draft-preview-ribbon span { display: none; }
.media-picker-backdrop { padding: 10px; }
.media-picker-dialog { max-height: 94vh; padding: 18px; border-radius: 18px; }
.media-picker-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.login-page { padding: 18px; background-position: 66% center; }
.login-brand { min-height: 110px; padding: 4px; }
.login-brand > div { display: none; }
.login-card { padding: 38px 24px; border-radius: 26px; }
.admin-shell { grid-template-columns: 1fr; }
.admin-sidebar { display: none; }
.admin-topbar { align-items: flex-start; gap: 12px; padding: 14px 16px; }
.admin-topbar > div:first-child small { display: none; }
.admin-actions a, .save-message { display: none; }
.admin-actions button { padding: 0 10px; font-size: 12px; }
.admin-workspace { padding: 12px; }
.content-admin-body, .settings-admin-body { padding: 12px; }
.media-admin-body { padding: 12px; }
.media-upload-panel, .media-library-layout { grid-template-columns: 1fr; }
.media-detail { position: static; }
.content-layout { grid-template-columns: 1fr; }
.content-list { max-height: 300px; overflow: auto; }
.content-editor { padding: 20px 16px; }
.editor-panel { padding: 20px 16px; }
.form-grid { grid-template-columns: 1fr; }
.form-grid label.full { grid-column: 1; }
}
@media (max-width: 520px) {
.team-grid { padding-right: 20px; padding-left: 20px; }
.team-grid article { grid-template-columns: 1fr; gap: 16px; padding: 18px; }
.team-grid img { width: 100%; height: auto; aspect-ratio: 4 / 3; }
.team-grid h2, .team-grid h3 { margin-top: 4px; }
}
@media (hover: none) {
.metric-card:hover, .philosophy-card:hover, .product-card:hover, .official-grid article:hover, .listing-card:hover { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
.reveal-section { opacity: 1; transform: none; }
.reveal-section > *, .metric-card, .teacher-card { opacity: 1; clip-path: none; transform: none; }
.hero-media img, .hero-copy, .teacher-portrait img, .about-visual img { transform: none !important; }
}

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>

View File

@@ -0,0 +1,32 @@
import type { SiteDocument } from '../../server/utils/site-schema'
export async function useSiteDocument() {
const route = useRoute()
const isPreview = computed(() => route.query.preview === 'draft')
const endpoint = computed(() => isPreview.value ? '/api/admin/site' : '/api/public/site')
const headers = import.meta.server && isPreview.value ? useRequestHeaders(['cookie']) : undefined
useHead(() => isPreview.value ? { meta: [{ name: 'robots', content: 'noindex, nofollow' }] } : {})
const { data: response, error } = await useFetch<{ data: SiteDocument }>(endpoint, { headers })
if (error.value) {
if (isPreview.value && error.value.statusCode === 401) {
throw createError({ statusCode: 401, statusMessage: '预览草稿前请先登录后台' })
}
throw error.value
}
return {
site: computed(() => response.value!.data),
isPreview,
}
}
export function useAbsoluteUrl() {
const config = useRuntimeConfig()
const base = String(config.public.siteUrl).replace(/\/$/, '')
return (path?: string | null) => {
if (!path) return base
if (/^https?:\/\//i.test(path)) return path
return `${base}${path.startsWith('/') ? path : `/${path}`}`
}
}

View File

@@ -0,0 +1,35 @@
import type { Ref } from 'vue'
export function useUnsavedChanges<T>(source: Ref<T | undefined>) {
const dirty = ref(false)
let baseline = ''
function snapshot() {
baseline = JSON.stringify(source.value ?? null)
dirty.value = false
}
watch(source, (value) => {
if (!baseline) return
dirty.value = JSON.stringify(value ?? null) !== baseline
}, { deep: true })
function beforeUnload(event: BeforeUnloadEvent) {
if (!dirty.value) return
event.preventDefault()
event.returnValue = ''
}
onMounted(() => window.addEventListener('beforeunload', beforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload))
onBeforeRouteLeave(() => !dirty.value || window.confirm('当前修改尚未保存,确定离开吗?'))
return { dirty, markClean: snapshot }
}
export function apiErrorMessage(error: any, fallback: string) {
const issue = error?.data?.data?.issues?.[0]
if (!issue) return error?.data?.statusMessage || fallback
const path = Array.isArray(issue.path) && issue.path.length ? `${issue.path.join('.')}` : ''
return `${path}${issue.message || fallback}`
}

149
app/pages/admin/content.vue Normal file
View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { PhCheckCircle, PhFloppyDisk, PhPlus, PhTrash, PhUploadSimple } from '@phosphor-icons/vue'
import { apiErrorMessage } from '~/composables/useUnsavedChanges'
import type { SiteDocument } from '../../../server/utils/site-schema'
definePageMeta({ layout: false })
useHead(() => ({ title: `内容管理|${document.value?.brand.name || '慧遇书院'}` }))
type ContentTab = 'products' | 'articles' | 'team'
const tab = ref<ContentTab>('products')
const selected = reactive({ products: 0, articles: 0, team: 0 })
const document = ref<SiteDocument>()
const loading = ref(true)
const busy = ref(false)
const message = ref('')
const { dirty, markClean } = useUnsavedChanges(document)
onMounted(async () => {
try {
await $fetch('/api/auth/me')
document.value = (await $fetch<{ data: SiteDocument }>('/api/admin/site')).data
await nextTick()
markClean()
} catch { await navigateTo('/admin/login') }
finally { loading.value = false }
})
const currentProduct = computed(() => document.value?.products[selected.products])
const currentArticle = computed(() => document.value?.articles[selected.articles])
const currentMember = computed(() => document.value?.team[selected.team])
function makeSlug(prefix: string) { return `${prefix}-${Date.now().toString(36)}` }
function addItem() {
if (!document.value) return
if (tab.value === 'products') {
document.value.products.push({ slug: makeSlug('product'), title: '新产品', subtitle: '', type: 'course', summary: '', content: '', image: '/images/hero-bg-v3.webp', href: '', status: 'draft' })
selected.products = document.value.products.length - 1
} else if (tab.value === 'articles') {
document.value.articles.push({ slug: makeSlug('article'), type: 'news', title: '新内容', summary: '', content: '', cover: '/images/hero-bg-v3.webp', publishedAt: new Date().toISOString().slice(0, 10), status: 'draft', statementNo: '', sourceName: '', sourceUrl: '' })
selected.articles = document.value.articles.length - 1
} else {
document.value.team.push({ slug: makeSlug('member'), name: '新成员', role: '', summary: '', avatar: '/images/lu-hui-portrait.jpg', specialties: [], status: 'draft' })
selected.team = document.value.team.length - 1
}
}
function removeItem() {
if (!document.value || !window.confirm('确定删除当前内容吗?保存后生效。')) return
document.value[tab.value].splice(selected[tab.value], 1 as never)
selected[tab.value] = Math.max(0, selected[tab.value] - 1)
}
async function save(publish = false) {
if (!document.value) return
busy.value = true
message.value = ''
try {
await $fetch('/api/admin/site', { method: 'PUT', body: document.value })
if (publish) {
const result = await $fetch<{ version: number }>('/api/admin/site/publish', { method: 'POST' })
message.value = `已发布 V${result.version}`
} else message.value = '草稿已保存'
markClean()
} catch (error: any) { message.value = apiErrorMessage(error, '保存失败,请检查必填项和英文标识') }
finally { busy.value = false }
}
const previewHref = computed(() => {
if (tab.value === 'products' && currentProduct.value) return `/products/${currentProduct.value.slug}?preview=draft`
if (tab.value === 'articles' && currentArticle.value) return `${currentArticle.value.type === 'statement' ? '/statements' : '/news'}/${currentArticle.value.slug}?preview=draft`
return '/team?preview=draft'
})
async function uploadImage(event: Event, apply: (url: string) => void) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
busy.value = true
try {
const body = new FormData(); body.append('file', file)
const result = await $fetch<{ url: string }>('/api/admin/media', { method: 'POST', body })
apply(result.url); message.value = '图片已上传,请保存并发布'
} catch (error: any) { message.value = error?.data?.statusMessage || '图片上传失败' }
finally { busy.value = false; input.value = '' }
}
</script>
<template>
<main class="admin-shell">
<AdminSidebar active="content" :logo="document?.brand.logo" :brand-name="document?.brand.name" :english-name="document?.brand.englishName" />
<section class="admin-main">
<header class="admin-topbar">
<div><p>内容管理</p><small>课程产品品牌动态官方声明与陪伴团队</small></div>
<div class="admin-actions">
<span v-if="dirty" class="dirty-indicator">有未保存修改</span>
<span v-if="message" class="save-message"><PhCheckCircle :size="18" />{{ message }}</span>
<a :href="previewHref" target="_blank">预览草稿</a>
<button :disabled="busy" @click="save(false)"><PhFloppyDisk :size="18" />保存草稿</button>
<button class="publish-button" :disabled="busy" @click="save(true)"><PhUploadSimple :size="18" />发布更新</button>
</div>
</header>
<div v-if="loading" class="admin-loading">正在加载内容</div>
<div v-else-if="document" class="content-admin-body">
<div class="content-tabs">
<button v-for="item in ([['products','课程与产品'],['articles','动态与声明'],['team','陪伴团队']] as const)" :key="item[0]" :class="{ active: tab === item[0] }" @click="tab = item[0]">{{ item[1] }}</button>
</div>
<div class="content-layout">
<aside class="content-list">
<button class="add-content" @click="addItem"><PhPlus :size="18" />新建内容</button>
<template v-if="tab === 'products'">
<button v-for="(item, index) in document.products" :key="item.slug" :class="{ active: selected.products === index }" @click="selected.products = index"><strong>{{ item.title }}</strong><small>{{ item.status }} · {{ item.slug }}</small></button>
</template>
<template v-if="tab === 'articles'">
<button v-for="(item, index) in document.articles" :key="item.slug" :class="{ active: selected.articles === index }" @click="selected.articles = index"><strong>{{ item.title }}</strong><small>{{ item.type }} · {{ item.status }}</small></button>
</template>
<template v-if="tab === 'team'">
<button v-for="(item, index) in document.team" :key="item.slug" :class="{ active: selected.team === index }" @click="selected.team = index"><strong>{{ item.name }}</strong><small>{{ item.role || '未填写角色' }} · {{ item.status }}</small></button>
</template>
</aside>
<section class="content-editor">
<div class="panel-heading"><div><span>编辑</span><h1>{{ tab === 'products' ? '课程与产品' : tab === 'articles' ? '动态与声明' : '陪伴团队' }}</h1></div><button class="danger-link" :disabled="!(document[tab]?.length)" @click="removeItem"><PhTrash :size="17" />删除</button></div>
<div v-if="tab === 'products' && currentProduct" class="form-grid">
<label>标题<input v-model="currentProduct.title" maxlength="60"></label><label>英文标识<input v-model="currentProduct.slug" placeholder="family-course"></label>
<label>类型<select v-model="currentProduct.type"><option value="course">课程</option><option value="service">服务</option><option value="tool">工具</option></select></label><label>状态<select v-model="currentProduct.status"><option value="draft">草稿</option><option value="published">已发布</option><option value="offline">已下线</option></select></label>
<label class="full">副标题<input v-model="currentProduct.subtitle"></label><label class="full">摘要<textarea v-model="currentProduct.summary" rows="3" /></label><label class="full">正文<textarea v-model="currentProduct.content" rows="12" /></label>
<label class="full">封面地址<input v-model="currentProduct.image"><span class="compact-upload"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, url => currentProduct!.image = url)">上传本地图片</span><MediaPicker v-model="currentProduct.image" /></label>
</div>
<div v-else-if="tab === 'articles' && currentArticle" class="form-grid">
<label>标题<input v-model="currentArticle.title"></label><label>英文标识<input v-model="currentArticle.slug"></label>
<label>类型<select v-model="currentArticle.type"><option value="news">品牌动态</option><option value="statement">官方声明</option><option value="media">媒体报道</option></select></label><label>状态<select v-model="currentArticle.status"><option value="draft">草稿</option><option value="published">已发布</option><option value="offline">已下线</option></select></label>
<label>发布日期<input v-model="currentArticle.publishedAt" type="date"></label><label>声明编号<input v-model="currentArticle.statementNo"></label>
<label class="full">摘要<textarea v-model="currentArticle.summary" rows="3" /></label><label class="full">正文<textarea v-model="currentArticle.content" rows="14" /></label>
<label>来源名称<input v-model="currentArticle.sourceName"></label><label>来源链接<input v-model="currentArticle.sourceUrl"></label>
<label class="full">封面地址<input v-model="currentArticle.cover"><span class="compact-upload"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, url => currentArticle!.cover = url)">上传本地图片</span><MediaPicker v-model="currentArticle.cover" /></label>
</div>
<div v-else-if="tab === 'team' && currentMember" class="form-grid">
<label>姓名<input v-model="currentMember.name"></label><label>英文标识<input v-model="currentMember.slug"></label>
<label>角色<input v-model="currentMember.role"></label><label>状态<select v-model="currentMember.status"><option value="draft">草稿</option><option value="published">已发布</option><option value="offline">已下线</option></select></label>
<label class="full">简介<textarea v-model="currentMember.summary" rows="5" /></label><label class="full">专长(用中文逗号分隔)<input :value="currentMember.specialties.join('')" @input="currentMember.specialties = ($event.target as HTMLInputElement).value.split(/[,]/).map(v => v.trim()).filter(Boolean).slice(0, 8)"></label>
<label class="full">头像地址<input v-model="currentMember.avatar"><span class="compact-upload"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, url => currentMember!.avatar = url)">上传本地图片</span><MediaPicker v-model="currentMember.avatar" /></label>
</div>
<div v-else class="empty-editor">当前分类暂无内容点击新建内容开始编辑</div>
</section>
</div>
</div>
</section>
</main>
</template>

176
app/pages/admin/index.vue Normal file
View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import {
PhArrowSquareOut as ArrowSquareOut,
PhCheckCircle as CheckCircle,
PhFloppyDisk as FloppyDisk,
PhImageSquare as ImageSquare,
PhUploadSimple as UploadSimple,
} from '@phosphor-icons/vue'
import { apiErrorMessage } from '~/composables/useUnsavedChanges'
import type { SiteDocument } from '../../../server/utils/site-schema'
definePageMeta({ layout: false })
useHead(() => ({ title: `首页管理|${document.value?.brand.name || '慧遇书院'}` }))
const loading = ref(true)
const saving = ref(false)
const publishing = ref(false)
const message = ref('')
const document = ref<SiteDocument>()
const user = ref<{ displayName: string }>()
const uploadBusy = ref<string>()
const { dirty, markClean } = useUnsavedChanges(document)
onMounted(async () => {
try {
const [me, site] = await Promise.all([
$fetch<{ user: { displayName: string } }>('/api/auth/me'),
$fetch<{ data: SiteDocument }>('/api/admin/site'),
])
user.value = me.user
document.value = site.data
await nextTick()
markClean()
} catch {
await navigateTo('/admin/login')
} finally {
loading.value = false
}
})
async function save() {
if (!document.value) return
saving.value = true
message.value = ''
try {
await $fetch('/api/admin/site', { method: 'PUT', body: document.value })
message.value = '草稿已保存'
markClean()
} catch (error: any) {
message.value = apiErrorMessage(error, '保存失败,请检查页面内容')
} finally {
saving.value = false
}
}
async function publish() {
await save()
publishing.value = true
try {
const result = await $fetch<{ version: number }>('/api/admin/site/publish', { method: 'POST' })
message.value = `已发布 V${result.version}`
markClean()
} catch (error: any) {
message.value = apiErrorMessage(error, '发布失败,请检查页面内容')
} finally {
publishing.value = false
}
}
async function uploadImage(event: Event, target: 'brand.logo' | 'hero.backgroundImage' | 'teacher.portrait') {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !document.value) return
uploadBusy.value = target
message.value = ''
try {
const body = new FormData()
body.append('file', file)
const result = await $fetch<{ url: string }>('/api/admin/media', { method: 'POST', body })
if (target === 'brand.logo') document.value.brand.logo = result.url
if (target === 'hero.backgroundImage') document.value.hero.backgroundImage = result.url
if (target === 'teacher.portrait') document.value.teacher.portrait = result.url
message.value = '图片已上传,请保存草稿并发布'
} catch (error: any) {
message.value = error?.data?.statusMessage || '图片上传失败'
} finally {
uploadBusy.value = undefined
input.value = ''
}
}
</script>
<template>
<main class="admin-shell">
<AdminSidebar active="home" :logo="document?.brand.logo" :brand-name="document?.brand.name" :english-name="document?.brand.englishName" />
<section class="admin-main">
<header class="admin-topbar">
<div><p>首页管理</p><small>维护官网核心文案并实时预览</small></div>
<div class="admin-actions">
<span v-if="dirty" class="dirty-indicator">有未保存修改</span>
<span v-if="message" class="save-message"><CheckCircle :size="18" />{{ message }}</span>
<a href="/?preview=draft" target="_blank"><ArrowSquareOut :size="19" />预览草稿</a>
<button type="button" :disabled="saving" @click="save"><FloppyDisk :size="19" />{{ saving ? '保存中' : '保存草稿' }}</button>
<button class="publish-button" type="button" :disabled="publishing" @click="publish"><UploadSimple :size="19" />{{ publishing ? '发布中' : '发布更新' }}</button>
</div>
</header>
<div v-if="loading" class="admin-loading">正在加载配置</div>
<div v-else-if="document" class="admin-workspace">
<section class="editor-panel">
<div class="panel-heading"><div><span>00</span><h1>公司与品牌</h1></div><small>全站统一信息</small></div>
<div class="form-grid">
<label>官网展示名称<input v-model="document.brand.name" maxlength="40" placeholder="例如:慧遇书院"></label>
<label>公司主体全称<input v-model="document.brand.companyName" maxlength="100" placeholder="营业执照登记名称"></label>
<label>英文名称<input v-model="document.brand.englishName" maxlength="80"></label>
<label>品牌主张<input v-model="document.brand.slogan" maxlength="40"></label>
<label>品牌副文案<input v-model="document.brand.subSlogan" maxlength="80"></label>
<label class="full upload-field">品牌 Logo
<span class="image-input"><img :src="document.brand.logo" alt="当前品牌 Logo"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, 'brand.logo')"><b>{{ uploadBusy === 'brand.logo' ? '上传中…' : '选择图片' }}</b></span>
<MediaPicker v-model="document.brand.logo" />
</label>
</div>
<div class="panel-heading secondary"><div><span>01</span><h2>英雄区</h2></div><small>首屏核心信息</small></div>
<div class="form-grid">
<label>第一行标题<input v-model="document.hero.titleLine1" maxlength="20"></label>
<label>第二行标题<input v-model="document.hero.titleLine2" maxlength="20"></label>
<label class="full">副标题<input v-model="document.hero.subtitle" maxlength="80"></label>
<label>按钮文字<input v-model="document.hero.cta" maxlength="20"></label>
<label>SEO 标题<input v-model="document.seo.title" maxlength="70"></label>
<label class="full upload-field">首屏背景图
<span class="image-input"><img :src="document.hero.backgroundImage" alt="当前首屏背景"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, 'hero.backgroundImage')"><b>{{ uploadBusy === 'hero.backgroundImage' ? '上传中…' : '选择图片' }}</b></span>
<MediaPicker v-model="document.hero.backgroundImage" />
</label>
</div>
<div id="teacher-form" class="panel-heading secondary"><div><span>02</span><h2>卢慧老师</h2></div><small>首页展示摘要</small></div>
<div class="form-grid">
<label>姓名<input v-model="document.teacher.name" maxlength="30"></label>
<label>身份说明<input v-model="document.teacher.title" maxlength="80"></label>
<label class="full">简介<textarea v-model="document.teacher.summary" rows="5" maxlength="400" /></label>
<label class="full upload-field">人物肖像
<span class="image-input portrait"><img :src="document.teacher.portrait" :alt="document.teacher.name"><input type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage($event, 'teacher.portrait')"><b>{{ uploadBusy === 'teacher.portrait' ? '上传中…' : '选择图片' }}</b></span>
<MediaPicker v-model="document.teacher.portrait" />
</label>
</div>
<div class="panel-heading secondary"><div><span>03</span><h2>联系与搜索信息</h2></div><small>页脚和搜索引擎展示</small></div>
<div class="form-grid">
<label>联系电话<input v-model="document.contact.phone" maxlength="30"></label>
<label>联系邮箱<input v-model="document.contact.email" maxlength="100"></label>
<label class="full">公司地址<input v-model="document.contact.address" maxlength="160"></label>
<label class="full">SEO 描述<textarea v-model="document.seo.description" rows="3" maxlength="180" /></label>
</div>
<div id="media" class="media-placeholder">
<ImageSquare :size="32" weight="light" />
<div><strong>本地素材存储</strong><p>一期图片保存在服务器数据卷Logo首屏背景与人物肖像已支持上传配置</p></div>
</div>
</section>
<aside class="preview-panel">
<div class="preview-label"><span>网站实时预览</span><small>{{ user?.displayName }}</small></div>
<div class="preview-window" :style="{ backgroundImage: `url(${document.hero.backgroundImage})` }">
<BrandLogo :src="document.brand.logo" :name="document.brand.name" :english-name="document.brand.englishName" />
<div><h2>{{ document.hero.titleLine1 }}<br>{{ document.hero.titleLine2 }}</h2><p>{{ document.hero.subtitle }}</p><span>{{ document.hero.cta }} </span></div>
</div>
<div class="preview-teacher">
<img :src="document.teacher.portrait" :alt="document.teacher.name">
<div><h3>{{ document.teacher.name }}</h3><p>{{ document.teacher.title }}</p><small>{{ document.teacher.summary }}</small></div>
</div>
</aside>
</div>
</section>
</main>
</template>

58
app/pages/admin/login.vue Normal file
View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import {
PhArrowRight as ArrowRight,
PhEye as Eye,
PhEyeSlash as EyeSlash,
PhLockKey as LockKey,
PhUser as User,
} from '@phosphor-icons/vue'
definePageMeta({ layout: false })
const { data: publicSite } = await useFetch<{ data: { brand: { name: string; englishName: string; logo: string; subSlogan: string } } }>('/api/public/site')
const loginBrand = computed(() => publicSite.value?.data.brand ?? { name: '慧遇书院', englishName: 'HUIYU ACADEMY', logo: '/images/brand-mark-v2.png', subSlogan: '用科技与专业,陪伴每一次心灵的成长' })
useHead(() => ({ title: `后台管理登录|${loginBrand.value.name}` }))
const form = reactive({ username: 'admin', password: '' })
const showPassword = ref(false)
const loading = ref(false)
const error = ref('')
async function login() {
error.value = ''
loading.value = true
try {
await $fetch('/api/auth/login', { method: 'POST', body: form })
await navigateTo('/admin')
} catch (cause: any) {
error.value = cause?.data?.statusMessage || '登录失败,请稍后重试'
} finally {
loading.value = false
}
}
</script>
<template>
<main class="login-page">
<div class="login-brand">
<BrandLogo :src="loginBrand.logo" :name="loginBrand.name" :english-name="loginBrand.englishName" />
<div>
<h1>身心疗愈<br>科技赋能</h1>
<p>{{ loginBrand.subSlogan }}</p>
</div>
</div>
<section class="login-card">
<p class="eyebrow">HUIYU CONTENT SYSTEM</p>
<h2>后台管理登录</h2>
<p class="login-intro">欢迎回来请登录管理员账号</p>
<form @submit.prevent="login">
<label>账号</label>
<div class="field"><User :size="22" /><input v-model.trim="form.username" autocomplete="username" placeholder="请输入账号" required></div>
<label>密码</label>
<div class="field"><LockKey :size="22" /><input v-model="form.password" :type="showPassword ? 'text' : 'password'" autocomplete="current-password" placeholder="请输入密码" required><button type="button" aria-label="切换密码显示" @click="showPassword = !showPassword"><EyeSlash v-if="showPassword" :size="21" /><Eye v-else :size="21" /></button></div>
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
<button class="login-submit" type="submit" :disabled="loading"><span>{{ loading ? '登录中…' : '登录后台' }}</span><ArrowRight :size="21" /></button>
</form>
<p class="security-note">安全连接已启用登录状态受保护</p>
</section>
</main>
</template>

111
app/pages/admin/media.vue Normal file
View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { PhArrowCounterClockwise, PhCheckCircle, PhFloppyDisk, PhImageSquare, PhTrash, PhUploadSimple } from '@phosphor-icons/vue'
import type { SiteDocument } from '../../../server/utils/site-schema'
definePageMeta({ layout: false })
useHead(() => ({ title: `素材中心|${site.value?.brand.name || '慧遇书院'}` }))
interface MediaAsset {
id: number; key: string; originalName: string; mimeType: string; bytes: number; width: number; height: number
url: string; thumbnailUrl: string; altText: string; source: string; copyright: string
authorizationStatus: 'pending' | 'authorized' | 'restricted'; status: 'active' | 'trash' | 'deleted'; referenced: boolean
createdAt: string; deletedAt: string | null
}
const site = ref<SiteDocument>()
const items = ref<MediaAsset[]>([])
const selectedId = ref<number>()
const view = ref<'active' | 'trash'>('active')
const loading = ref(true), busy = ref(false), message = ref('')
const uploadMeta = reactive({ altText: '', source: '', copyright: '', authorizationStatus: 'pending' as MediaAsset['authorizationStatus'] })
const selected = computed(() => items.value.find(item => item.id === selectedId.value))
onMounted(async () => {
try {
await $fetch('/api/auth/me')
site.value = (await $fetch<{ data: SiteDocument }>('/api/admin/site')).data
await loadItems()
} catch { await navigateTo('/admin/login') }
finally { loading.value = false }
})
async function loadItems() {
const result = await $fetch<{ data: MediaAsset[] }>('/api/admin/media', { query: { status: view.value } })
items.value = result.data
selectedId.value = items.value[0]?.id
}
async function changeView(next: 'active' | 'trash') { view.value = next; message.value = ''; await loadItems() }
async function upload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
busy.value = true; message.value = ''
try {
const body = new FormData(); body.append('file', file)
Object.entries(uploadMeta).forEach(([key, value]) => body.append(key, value))
const result = await $fetch<{ media: MediaAsset }>('/api/admin/media', { method: 'POST', body })
items.value.unshift(result.media); selectedId.value = result.media.id
uploadMeta.altText = ''; uploadMeta.source = ''; uploadMeta.copyright = ''; uploadMeta.authorizationStatus = 'pending'
message.value = '素材已上传并生成 WebP 与缩略图'
} catch (error: any) { message.value = error?.data?.statusMessage || '上传失败' }
finally { busy.value = false; input.value = '' }
}
async function saveMetadata() {
if (!selected.value) return
busy.value = true; message.value = ''
try {
await $fetch(`/api/admin/media/${selected.value.id}`, { method: 'PATCH', body: { altText: selected.value.altText, source: selected.value.source, copyright: selected.value.copyright, authorizationStatus: selected.value.authorizationStatus } })
message.value = '素材信息已保存'
} catch (error: any) { message.value = error?.data?.statusMessage || '保存失败' }
finally { busy.value = false }
}
async function trash() {
if (!selected.value || !window.confirm('确定把该素材移入回收站吗?')) return
busy.value = true; message.value = ''
try { await $fetch(`/api/admin/media/${selected.value.id}`, { method: 'DELETE' }); message.value = '素材已移入回收站'; await loadItems() }
catch (error: any) { message.value = error?.data?.statusMessage || '删除失败' }
finally { busy.value = false }
}
async function restore() {
if (!selected.value) return
busy.value = true
try { await $fetch(`/api/admin/media/${selected.value.id}/restore`, { method: 'POST' }); message.value = '素材已恢复'; await loadItems() }
catch (error: any) { message.value = error?.data?.statusMessage || '恢复失败' }
finally { busy.value = false }
}
const formatBytes = (bytes: number) => bytes < 1024 * 1024 ? `${Math.ceil(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`
</script>
<template>
<main class="admin-shell">
<AdminSidebar active="media" :logo="site?.brand.logo" :brand-name="site?.brand.name" :english-name="site?.brand.englishName" />
<section class="admin-main">
<header class="admin-topbar"><div><p>素材中心</p><small>原图WebP 衍生图授权信息与安全回收站</small></div><div class="admin-actions"><span v-if="message" class="save-message"><PhCheckCircle :size="18" />{{ message }}</span><button :class="{ 'publish-button': view === 'active' }" @click="changeView('active')">素材库</button><button :class="{ 'publish-button': view === 'trash' }" @click="changeView('trash')">回收站</button></div></header>
<div v-if="loading" class="admin-loading">正在加载素材</div>
<div v-else class="media-admin-body">
<section v-if="view === 'active'" class="media-upload-panel editor-panel">
<div><PhUploadSimple :size="30" /><strong>上传新图片</strong><small>支持 JPGPNGWebP最大 8MB4000 万像素</small></div>
<div class="media-upload-meta"><input v-model="uploadMeta.altText" placeholder="ALT 描述(建议填写)"><input v-model="uploadMeta.source" placeholder="素材来源"><input v-model="uploadMeta.copyright" placeholder="版权/授权说明"><select v-model="uploadMeta.authorizationStatus"><option value="pending">待核验</option><option value="authorized">已授权</option><option value="restricted">限制使用</option></select></div>
<label class="media-upload-button"><input type="file" accept="image/jpeg,image/png,image/webp" :disabled="busy" @change="upload">{{ busy ? '处理中' : '选择并上传图片' }}</label>
</section>
<div class="media-library-layout">
<section class="media-grid-panel">
<div v-if="!items.length" class="empty-editor"><PhImageSquare :size="38" /><p>{{ view === 'active' ? '尚未上传素材' : '回收站为空' }}</p></div>
<button v-for="item in items" :key="item.id" :class="['media-tile', { active: item.id === selectedId }]" @click="selectedId = item.id"><img :src="item.thumbnailUrl" :alt="item.altText || item.originalName"><span>{{ item.originalName }}</span><small>{{ item.width }}×{{ item.height }} · {{ formatBytes(item.bytes) }}</small><b v-if="item.referenced">使用中</b></button>
</section>
<aside v-if="selected" class="media-detail editor-panel">
<img :src="selected.url" :alt="selected.altText || selected.originalName"><div class="media-detail-meta"><strong>{{ selected.originalName }}</strong><small>{{ selected.mimeType }} · {{ selected.width }}×{{ selected.height }} · {{ formatBytes(selected.bytes) }}</small><a :href="selected.url" target="_blank">打开衍生图</a></div>
<div v-if="view === 'active'" class="form-grid"><label class="full">ALT 描述<input v-model="selected.altText" maxlength="160"></label><label class="full">素材来源<input v-model="selected.source" maxlength="200"></label><label class="full">版权/授权说明<input v-model="selected.copyright" maxlength="200"></label><label class="full">授权状态<select v-model="selected.authorizationStatus"><option value="pending">待核验</option><option value="authorized">已授权</option><option value="restricted">限制使用</option></select></label></div>
<div class="media-detail-actions"><template v-if="view === 'active'"><button :disabled="busy" @click="saveMetadata"><PhFloppyDisk :size="17" />保存信息</button><button class="danger-link" :disabled="busy || selected.referenced" @click="trash"><PhTrash :size="17" />{{ selected.referenced ? '页面使用中' : '移入回收站' }}</button></template><button v-else :disabled="busy" @click="restore"><PhArrowCounterClockwise :size="17" />恢复素材</button></div>
</aside>
</div>
</div>
</section>
</main>
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { PhCheckCircle, PhFloppyDisk, PhPlus, PhTrash, PhUploadSimple } from '@phosphor-icons/vue'
import { apiErrorMessage } from '~/composables/useUnsavedChanges'
import type { SiteDocument } from '../../../server/utils/site-schema'
definePageMeta({ layout: false })
useHead(() => ({ title: `网站设置|${document.value?.brand.name || '慧遇书院'}` }))
const document = ref<SiteDocument>()
const loading = ref(true), busy = ref(false), message = ref('')
const revisions = ref<Array<{ version: number; action: string; comment: string | null; createdAt: string }>>([])
const auditLogs = ref<Array<{ id: number; action: string; entityType: string; entityKey: string | null; ipAddress: string | null; createdAt: string; operator: string }>>([])
type HealthStatus = { status: string; version: string; checks: { database: string; uploads: string; freeBytes: number } }
const health = ref<HealthStatus>()
const passwords = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' })
const { dirty, markClean } = useUnsavedChanges(document)
onMounted(async () => {
try {
await $fetch('/api/auth/me')
const [siteResult, revisionResult, auditResult, healthResult] = await Promise.all([$fetch<{ data: SiteDocument }>('/api/admin/site'), $fetch<{ data: typeof revisions.value }>('/api/admin/site/revisions'), $fetch<{ data: typeof auditLogs.value }>('/api/admin/audit'), $fetch<HealthStatus>('/api/health')])
document.value = siteResult.data; revisions.value = revisionResult.data; auditLogs.value = auditResult.data; health.value = healthResult
await nextTick(); markClean()
}
catch { await navigateTo('/admin/login') }
finally { loading.value = false }
})
async function save(publish = false) {
if (!document.value) return
busy.value = true; message.value = ''
try {
await $fetch('/api/admin/site', { method: 'PUT', body: document.value })
if (publish) { const result = await $fetch<{ version: number }>('/api/admin/site/publish', { method: 'POST' }); message.value = `已发布 V${result.version}` }
else message.value = '草稿已保存'
markClean()
} catch (error: any) { message.value = apiErrorMessage(error, '保存失败,请检查内容长度') }
finally { busy.value = false }
}
async function restoreVersion(version: number) {
if (!window.confirm(`确定把 V${version} 恢复为当前草稿吗?已发布网站不会立即变化。`)) return
busy.value = true
try { document.value = (await $fetch<{ data: SiteDocument }>(`/api/admin/site/revisions/${version}/restore`, { method: 'POST' })).data; await nextTick(); markClean(); message.value = `V${version} 已恢复为草稿,请检查后再发布` }
catch (error: any) { message.value = error?.data?.statusMessage || '恢复失败' }
finally { busy.value = false }
}
async function changePassword() {
if (passwords.newPassword !== passwords.confirmPassword) { message.value = '两次输入的新密码不一致'; return }
busy.value = true
try {
await $fetch('/api/auth/password', { method: 'PUT', body: { currentPassword: passwords.currentPassword, newPassword: passwords.newPassword } })
passwords.currentPassword = ''; passwords.newPassword = ''; passwords.confirmPassword = ''; message.value = '管理员密码已修改'
} catch (error: any) { message.value = error?.data?.statusMessage || error?.data?.data?.issues?.[0]?.message || '密码修改失败' }
finally { busy.value = false }
}
</script>
<template>
<main class="admin-shell"><AdminSidebar active="settings" :logo="document?.brand.logo" :brand-name="document?.brand.name" :english-name="document?.brand.englishName" />
<section class="admin-main"><header class="admin-topbar"><div><p>网站设置</p><small>公司主体人物资料官方渠道与法律文本</small></div><div class="admin-actions"><span v-if="dirty" class="dirty-indicator">有未保存修改</span><span v-if="message" class="save-message"><PhCheckCircle :size="18" />{{ message }}</span><a href="/luhui?preview=draft" target="_blank">预览人物页</a><button :disabled="busy" @click="save(false)"><PhFloppyDisk :size="18" />保存草稿</button><button class="publish-button" :disabled="busy" @click="save(true)"><PhUploadSimple :size="18" />发布更新</button></div></header>
<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>
</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>
</div><div class="subsection-heading"><h3>经历与实践</h3><button class="inline-add" @click="document.teacher.experiences.push({ year: '', title: '新经历', description: '' })"><PhPlus :size="17" />新增经历</button></div><div class="repeat-settings"><article v-for="(experience, index) in document.teacher.experiences" :key="index"><div class="form-grid"><label>年份<input v-model="experience.year"></label><label>标题<input v-model="experience.title"></label><label class="full">说明<textarea v-model="experience.description" rows="3" /></label></div><button class="danger-link" @click="document.teacher.experiences.splice(index, 1)"><PhTrash :size="16" />移除</button></article></div><div class="subsection-heading"><h3>外部权威链接</h3><button class="inline-add" @click="document.teacher.externalLinks.push({ label: '新链接', url: '' })"><PhPlus :size="17" />新增链接</button></div><div class="repeat-settings"><article v-for="(link, index) in document.teacher.externalLinks" :key="index"><div class="form-grid"><label>名称<input v-model="link.label"></label><label>链接<input v-model="link.url"></label></div><button class="danger-link" @click="document.teacher.externalLinks.splice(index, 1)"><PhTrash :size="16" />移除</button></article></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>03</span><h2>官方渠道</h2></div><button class="inline-add" @click="document.channels.push({ name: '新渠道', account: '', url: '', qrCode: '' })"><PhPlus :size="17" />新增渠道</button></div><div class="repeat-settings"><article v-for="(channel, index) in document.channels" :key="index"><div class="form-grid"><label>渠道名称<input v-model="channel.name"></label><label>账号<input v-model="channel.account"></label><label>跳转链接<input v-model="channel.url"></label><label>二维码图片地址<input v-model="channel.qrCode"><MediaPicker v-model="channel.qrCode" label="从素材库选择二维码" /></label></div><button class="danger-link" @click="document.channels.splice(index, 1)"><PhTrash :size="16" />移除</button></article></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>04</span><h2>法律文本</h2></div><small>纯文本安全展示</small></div><div class="form-grid"><label>隐私政策标题<input v-model="document.legal.privacyTitle"></label><label>服务条款标题<input v-model="document.legal.termsTitle"></label><label class="full">隐私政策<textarea v-model="document.legal.privacyContent" rows="12" /></label><label class="full">服务条款<textarea v-model="document.legal.termsContent" rows="12" /></label></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>05</span><h2>发布版本</h2></div><small>最近 20 个发布快照</small></div><div class="revision-list"><article v-for="revision in revisions" :key="revision.version"><div><strong>V{{ revision.version }}</strong><small>{{ new Date(revision.createdAt).toLocaleString('zh-CN') }}</small></div><button :disabled="busy" @click="restoreVersion(revision.version)">恢复为草稿</button></article><p v-if="!revisions.length">尚无发布历史</p></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>06</span><h2>管理员密码</h2></div><small>至少 12 包含大小写数字和特殊字符</small></div><form class="form-grid" @submit.prevent="changePassword"><label>当前密码<input v-model="passwords.currentPassword" type="password" autocomplete="current-password"></label><label>新密码<input v-model="passwords.newPassword" type="password" autocomplete="new-password"></label><label>确认新密码<input v-model="passwords.confirmPassword" type="password" autocomplete="new-password"></label><div class="password-action"><button class="inline-add" :disabled="busy">修改密码</button></div></form></section>
<section class="editor-panel"><div class="panel-heading"><div><span>07</span><h2>系统状态与操作记录</h2></div><small v-if="health" class="health-ok">运行正常 · {{ health.version }} · 可用空间 {{ (health.checks.freeBytes / 1024 / 1024 / 1024).toFixed(1) }} GB</small></div><div class="audit-list"><article v-for="log in auditLogs.slice(0, 20)" :key="log.id"><div><strong>{{ log.action }}</strong><span>{{ log.entityType }}<template v-if="log.entityKey"> · {{ log.entityKey }}</template></span></div><small>{{ log.operator }} · {{ new Date(log.createdAt).toLocaleString('zh-CN') }}</small></article><p v-if="!auditLogs.length">暂无操作记录</p></div></section>
</div>
</section>
</main>
</template>

204
app/pages/index.vue Normal file
View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
import {
PhArrowRight as ArrowRight,
PhBuildings as Buildings,
PhCalendarBlank as CalendarBlank,
PhCaretDown as CaretDown,
PhCpu as Cpu,
PhEnvelopeSimple as EnvelopeSimple,
PhLeaf as Leaf,
PhMapPin as MapPin,
PhPhone as Phone,
PhSealCheck as SealCheck,
PhSparkle as Sparkle,
PhUsersThree as UsersThree,
} from '@phosphor-icons/vue'
const { site, isPreview } = await useSiteDocument()
const config = useRuntimeConfig()
const absoluteUrl = useAbsoluteUrl()
const publishedProducts = computed(() => site.value.products.filter(item => isPreview.value ? item.status !== 'offline' : item.status === 'published'))
const news = computed(() => site.value.articles.filter(item => isPreview.value ? item.status !== 'offline' : item.status === 'published').slice(0, 4))
const team = computed(() => site.value.team.filter(item => isPreview.value ? item.status !== 'offline' : item.status === 'published').slice(0, 4))
const hero = ref<HTMLElement>()
let heroFrame = 0
const absoluteHero = computed(() => absoluteUrl(site.value.hero.backgroundImage))
const contactPhoneHref = computed(() => /[待演示]/.test(site.value.contact.phone) ? '' : `tel:${site.value.contact.phone.replace(/\s/g, '')}`)
const contactEmailHref = computed(() => site.value.contact.email.includes('@') && !site.value.contact.email.includes('演示') ? `mailto:${site.value.contact.email}` : '')
const usesDefaultHero = computed(() => site.value.hero.backgroundImage === '/images/hero-bg-v3.webp')
const previewTo = (path: string) => isPreview.value ? { path, query: { preview: 'draft' } } : path
useSeoMeta({
title: () => site.value?.seo.title,
description: () => site.value?.seo.description,
ogTitle: () => site.value?.seo.title,
ogDescription: () => site.value?.seo.description,
ogImage: () => absoluteHero.value,
})
useHead(() => ({
link: usesDefaultHero.value ? [{ rel: 'preload', as: 'image', href: '/images/hero-bg-v3.avif', type: 'image/avif' }] : [],
script: [{ type: 'application/ld+json', textContent: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Organization', name: site.value.brand.companyName, url: config.public.siteUrl, logo: absoluteUrl(site.value.brand.logo), description: site.value.seo.description, email: site.value.contact.email, telephone: site.value.contact.phone }) }],
}))
const philosophies = [
{ title: '觉察', subtitle: '看见自己,是改变的开始', icon: Sparkle },
{ title: '关系', subtitle: '关系是心灵的镜子', icon: UsersThree },
{ title: '成长', subtitle: '在觉察与实践中持续成长', icon: Leaf },
]
const updateHeroScroll = () => {
heroFrame = 0
hero.value?.style.setProperty('--hero-scroll', String(Math.min(1, window.scrollY / Math.max(window.innerHeight, 1))))
}
const onHeroScroll = () => { if (!heroFrame) heroFrame = requestAnimationFrame(updateHeroScroll) }
const onHeroPointer = (event: PointerEvent) => {
if (!hero.value) return
const bounds = hero.value.getBoundingClientRect()
hero.value.style.setProperty('--pointer-x', String((event.clientX - bounds.left) / bounds.width - 0.5))
hero.value.style.setProperty('--pointer-y', String((event.clientY - bounds.top) / bounds.height - 0.5))
}
onMounted(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
updateHeroScroll()
window.addEventListener('scroll', onHeroScroll, { passive: true })
hero.value?.addEventListener('pointermove', onHeroPointer, { passive: true })
})
onBeforeUnmount(() => {
if (import.meta.client) window.removeEventListener('scroll', onHeroScroll)
hero.value?.removeEventListener('pointermove', onHeroPointer)
if (heroFrame) cancelAnimationFrame(heroFrame)
})
</script>
<template>
<main id="main-content" class="site-shell">
<section id="home" ref="hero" class="hero">
<picture class="hero-media" aria-hidden="true">
<source v-if="usesDefaultHero" srcset="/images/hero-bg-v3.avif" type="image/avif">
<source v-if="usesDefaultHero" srcset="/images/hero-bg-v3.webp" type="image/webp">
<img :src="site.hero.backgroundImage" alt="" width="1672" height="941" fetchpriority="high" decoding="async">
</picture>
<SiteHeader :logo="site.brand.logo" :brand-name="site.brand.name" :english-name="site.brand.englishName" />
<div class="hero-copy">
<h1><span>{{ site.hero.titleLine1 }}</span><span>{{ site.hero.titleLine2 }}</span></h1>
<p class="hero-subtitle">{{ site.hero.subtitle }}</p>
<a href="#about" class="primary-button">{{ site.hero.cta }} <ArrowRight :size="20" /></a>
</div>
<a class="scroll-cue" href="#about" aria-label="向下滚动了解更多">
<span>向下探索</span>
<CaretDown :size="22" />
</a>
<div class="hero-wave" aria-hidden="true" />
</section>
<RevealSection id="about" class="about section-pad">
<div class="section-heading centered">
<p class="eyebrow">ABOUT HUIYU</p>
<h2>{{ site.about.title }}</h2>
<p>{{ site.about.summary }}</p>
</div>
<div class="about-layout">
<div class="about-visual">
<img src="/images/hero-bg-v3.webp" alt="晨光中的湖面与远山" width="3344" height="1882" loading="lazy" decoding="async">
</div>
<div class="metric-grid">
<article v-for="(metric, index) in site.about.metrics" :key="metric.label" class="metric-card" :style="{ '--delay': `${index * 90}ms` }">
<component :is="index === 0 ? Leaf : index === 1 ? UsersThree : CalendarBlank" :size="28" weight="light" />
<AnimatedMetric :value="metric.value" />
<span>{{ metric.label }}</span>
<small>{{ metric.description }}</small>
</article>
</div>
</div>
</RevealSection>
<RevealSection id="teacher" class="teacher-section section-pad">
<div class="teacher-card">
<div class="teacher-portrait">
<img :src="site.teacher.portrait" :alt="site.teacher.name" width="536" height="804" loading="lazy" decoding="async">
</div>
<div class="teacher-copy">
<p class="eyebrow">CORE MENTOR</p>
<h2>{{ site.teacher.name }}</h2>
<p class="teacher-title">{{ site.teacher.title }}</p>
<p class="teacher-summary">{{ site.teacher.summary }}</p>
<div class="tag-list">
<span v-for="tag in site.teacher.tags" :key="tag">{{ tag }}</span>
</div>
<NuxtLink :to="previewTo('/luhui')" class="text-link">了解更多 <ArrowRight :size="18" /></NuxtLink>
</div>
</div>
</RevealSection>
<RevealSection class="philosophy section-pad">
<div class="section-heading centered">
<p class="eyebrow">PHILOSOPHY</p>
<h2>理念与方法</h2>
</div>
<div class="philosophy-grid">
<article v-for="(item, index) in philosophies" :key="item.title" class="philosophy-card">
<span class="philosophy-index">0{{ index + 1 }}</span>
<component :is="item.icon" :size="30" weight="light" />
<h3>{{ item.title }}</h3>
<p>{{ item.subtitle }}</p>
</article>
</div>
</RevealSection>
<RevealSection id="products" class="products section-pad">
<div class="section-heading split-heading">
<div><p class="eyebrow">COURSES & PRODUCTS</p><h2>课程与产品</h2></div>
<p>围绕家庭关系个人成长与日常实践提供清晰可持续的陪伴路径</p>
</div>
<div class="product-grid">
<NuxtLink v-for="product in publishedProducts" :key="product.slug" :to="previewTo(`/products/${product.slug}`)" class="product-card">
<img :src="product.image" :alt="product.title" width="1536" height="1024" loading="lazy" decoding="async">
<div><h3>{{ product.title }}</h3><p>{{ product.subtitle }}</p></div>
<ArrowRight :size="20" />
</NuxtLink>
</div>
</RevealSection>
<RevealSection id="official" class="official section-pad">
<div class="section-heading centered"><p class="eyebrow">OFFICIAL INFORMATION</p><h2>官方信息</h2></div>
<div class="official-grid">
<article><Buildings :size="32" weight="light" /><h3>公司主体</h3><p>统一维护并对外公示</p></article>
<article><UsersThree :size="32" weight="light" /><h3>官方老师</h3><p>认证身份与官方资料</p></article>
<article><SealCheck :size="32" weight="light" /><h3>官方账号</h3><p>官方平台与联系方式</p></article>
<article><Cpu :size="32" weight="light" /><h3>科技产品</h3><p>智能工具与服务入口</p></article>
</div>
<div v-if="site.channels.length" class="authority-links centered-links"><h2>官方渠道</h2><template v-for="channel in site.channels" :key="channel.name"><a v-if="channel.url" :href="channel.url" target="_blank" rel="noopener noreferrer">{{ channel.name }} · {{ channel.account }}</a><span v-else class="disabled-channel" aria-disabled="true">{{ channel.name }} · {{ channel.account }}</span></template></div>
</RevealSection>
<RevealSection v-if="team.length" class="team-section section-pad">
<div class="section-heading centered"><p class="eyebrow">COMPANION TEAM</p><h2>陪伴团队</h2></div>
<div class="team-grid compact-team"><article v-for="member in team" :key="member.slug"><img :src="member.avatar" :alt="member.name" width="1536" height="1024" loading="lazy" decoding="async"><div><h3>{{ member.name }}</h3><p class="member-role">{{ member.role }}</p><p>{{ member.summary }}</p></div></article></div>
<div class="center-action"><NuxtLink :to="previewTo('/team')" class="text-link">查看团队介绍 <ArrowRight :size="18" /></NuxtLink></div>
</RevealSection>
<RevealSection id="news" class="news-section section-pad">
<div class="news-panel">
<div class="news-feature">
<p class="eyebrow">BRAND NEWS</p>
<h2>品牌动态</h2>
<p>发布官方声明品牌进展与课程信息建立长期可信的官方信息源</p>
</div>
<div class="news-list">
<NuxtLink v-for="item in news" :key="item.slug" :to="previewTo(item.type === 'statement' ? `/statements/${item.slug}` : `/news/${item.slug}`)"><span>{{ item.title }}</span><time>{{ item.publishedAt }}</time><ArrowRight :size="18" /></NuxtLink>
</div>
</div>
</RevealSection>
<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><Phone :size="18" /><a v-if="contactPhoneHref" :href="contactPhoneHref">{{ site.contact.phone }}</a><span v-else>{{ site.contact.phone }}</span></p>
<p><EnvelopeSimple :size="18" /><a v-if="contactEmailHref" :href="contactEmailHref">{{ site.contact.email }}</a><span v-else>{{ site.contact.email }}</span></p>
<p><MapPin :size="18" /> {{ 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>
</main>
</template>

21
app/pages/luhui.vue Normal file
View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
const { site } = await useSiteDocument()
const absoluteUrl = useAbsoluteUrl()
useSeoMeta({ title: () => `${site.value.teacher.name}${site.value.brand.name}`, description: () => site.value.teacher.summary, ogTitle: () => site.value.teacher.name, ogDescription: () => site.value.teacher.summary, ogImage: () => absoluteUrl(site.value.teacher.portrait) })
useHead(() => ({ script: [{ type: 'application/ld+json', textContent: JSON.stringify({ '@context': 'https://schema.org', '@type': 'Person', name: site.value.teacher.name, description: site.value.teacher.summary, image: absoluteUrl(site.value.teacher.portrait), jobTitle: site.value.teacher.title, worksFor: { '@type': 'Organization', name: site.value.brand.companyName } }) }] }))
</script>
<template>
<PublicPageLayout :site="site">
<section class="detail-hero teacher-detail-hero">
<div><p class="eyebrow">CORE MENTOR</p><h1>{{ site.teacher.name }}</h1><p>{{ site.teacher.title }}</p></div>
<img :src="site.teacher.portrait" :alt="site.teacher.name" width="536" height="804" decoding="async">
</section>
<section class="detail-content teacher-detail-content">
<article><h2>人物介绍</h2><p class="long-copy">{{ site.teacher.fullBio }}</p></article>
<aside><h3>核心方向</h3><div class="tag-list"><span v-for="tag in site.teacher.tags" :key="tag">{{ tag }}</span></div><h3>核心成果</h3><ul><li v-for="item in site.teacher.achievements" :key="item">{{ item }}</li></ul></aside>
</section>
<section class="timeline-section section-pad"><div class="section-heading centered"><p class="eyebrow">EXPERIENCE</p><h2>经历与实践</h2></div><div class="timeline-list"><article v-for="item in site.teacher.experiences" :key="`${item.year}-${item.title}`"><time>{{ item.year }}</time><div><h3>{{ item.title }}</h3><p>{{ item.description }}</p></div></article></div></section>
<section v-if="site.teacher.externalLinks.length" class="authority-links"><h2>外部权威链接</h2><a v-for="link in site.teacher.externalLinks" :key="link.url" :href="link.url" target="_blank" rel="noopener noreferrer">{{ link.label }} </a></section>
</PublicPageLayout>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
const route = useRoute(); const { site, isPreview } = await useSiteDocument(); const absoluteUrl = useAbsoluteUrl()
const article = computed(() => site.value.articles.find(item => item.slug === route.params.slug && (isPreview.value ? item.status !== 'offline' : item.status === 'published') && item.type !== 'statement'))
const related = computed(() => site.value.articles.filter(item => item.slug !== route.params.slug && item.type !== 'statement' && (isPreview.value ? item.status !== 'offline' : item.status === 'published')).slice(0, 3))
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><p class="long-copy">{{ article.content }}</p><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>

6
app/pages/news/index.vue Normal file
View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
const { site, isPreview } = await useSiteDocument()
const articles = computed(() => site.value.articles.filter(item => (isPreview.value ? item.status !== 'offline' : item.status === 'published') && item.type !== 'statement'))
useSeoMeta({ title: () => `品牌动态|${site.value.brand.name}`, description: () => `${site.value.brand.name}品牌动态与媒体信息。` })
</script>
<template><PublicPageLayout :site="site"><section class="listing-hero"><p class="eyebrow">BRAND NEWS</p><h1>品牌动态</h1><p>记录品牌进展课程信息与公开报道</p></section><section class="article-list"><NuxtLink v-for="item in articles" :key="item.slug" :to="{ path: `/news/${item.slug}`, query: isPreview ? { preview: 'draft' } : {} }"><img :src="item.cover" :alt="item.title" width="1536" height="1024" loading="lazy" decoding="async"><div><time>{{ item.publishedAt }}</time><h2>{{ item.title }}</h2><p>{{ item.summary }}</p></div><span></span></NuxtLink></section></PublicPageLayout></template>

2
app/pages/privacy.vue Normal file
View File

@@ -0,0 +1,2 @@
<script setup lang="ts">const { site } = await useSiteDocument(); useSeoMeta({ title: () => `${site.value.legal.privacyTitle}${site.value.brand.name}` })</script>
<template><PublicPageLayout :site="site"><article class="legal-page"><p class="eyebrow">LEGAL</p><h1>{{ site.legal.privacyTitle }}</h1><p class="long-copy">{{ site.legal.privacyContent }}</p></article></PublicPageLayout></template>

View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
const route = useRoute()
const { site, isPreview } = await useSiteDocument()
const absoluteUrl = useAbsoluteUrl()
const product = computed(() => site.value.products.find(item => item.slug === route.params.slug && (isPreview.value ? item.status !== 'offline' : item.status === 'published')))
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><p class="long-copy">{{ product.content }}</p><a class="primary-button" href="/#contact">咨询了解</a></div></article></PublicPageLayout></template>

View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
const { site, isPreview } = await useSiteDocument()
const products = computed(() => site.value.products.filter(item => isPreview.value ? item.status !== 'offline' : item.status === 'published'))
useSeoMeta({ title: () => `课程与产品|${site.value.brand.name}`, description: () => `${site.value.brand.name}课程、服务与科技产品。`, ogTitle: () => `课程与产品|${site.value.brand.name}`, ogDescription: () => `${site.value.brand.name}课程、服务与科技产品。` })
</script>
<template><PublicPageLayout :site="site"><section class="listing-hero"><p class="eyebrow">COURSES & PRODUCTS</p><h1>课程与产品</h1><p>围绕关系成长与日常实践提供清晰可持续的支持路径</p></section><section class="listing-grid"><NuxtLink v-for="item in products" :key="item.slug" :to="{ path: `/products/${item.slug}`, query: isPreview ? { preview: 'draft' } : {} }" class="listing-card"><img :src="item.image" :alt="item.title" width="1536" height="1024" loading="lazy" decoding="async"><div><small>{{ item.type === 'tool' ? '科技工具' : '成长课程' }}</small><h2>{{ item.title }}</h2><p>{{ item.summary || item.subtitle }}</p><span>查看详情 </span></div></NuxtLink></section></PublicPageLayout></template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
const route = useRoute(); const { site, isPreview } = await useSiteDocument()
const article = computed(() => site.value.articles.find(item => item.slug === route.params.slug && (isPreview.value ? item.status !== 'offline' : item.status === 'published') && item.type === 'statement'))
if (!article.value) throw createError({ statusCode: 404, statusMessage: '未找到该声明' })
useSeoMeta({ title: () => `${article.value!.title}${site.value.brand.name}`, description: () => article.value!.summary })
</script>
<template><PublicPageLayout :site="site"><article v-if="article" class="story-page statement-page"><NuxtLink class="detail-back" :to="{ path: '/statements', query: isPreview ? { preview: 'draft' } : {} }">← 返回官方声明</NuxtLink><header><p class="eyebrow">OFFICIAL STATEMENT</p><small>{{ article.statementNo }}</small><h1>{{ article.title }}</h1><p>{{ article.publishedAt }}</p></header><div class="story-body"><p class="lead">{{ article.summary }}</p><p class="long-copy">{{ article.content }}</p></div></article></PublicPageLayout></template>

View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
const { site, isPreview } = await useSiteDocument()
const statements = computed(() => site.value.articles.filter(item => (isPreview.value ? item.status !== 'offline' : item.status === 'published') && item.type === 'statement'))
useSeoMeta({ title: () => `官方声明|${site.value.brand.name}`, description: () => `${site.value.brand.name}官方声明与重要信息。` })
</script>
<template><PublicPageLayout :site="site"><section class="listing-hero"><p class="eyebrow">OFFICIAL STATEMENTS</p><h1>官方声明</h1><p>统一发布重要说明帮助访问者辨别可信信息</p></section><section class="statement-list"><NuxtLink v-for="item in statements" :key="item.slug" :to="{ path: `/statements/${item.slug}`, query: isPreview ? { preview: 'draft' } : {} }"><div><small>{{ item.statementNo }}</small><h2>{{ item.title }}</h2><p>{{ item.summary }}</p></div><time>{{ item.publishedAt }}</time></NuxtLink></section></PublicPageLayout></template>

6
app/pages/team.vue Normal file
View File

@@ -0,0 +1,6 @@
<script setup lang="ts">
const { site, isPreview } = await useSiteDocument()
const members = computed(() => site.value.team.filter(item => isPreview.value ? item.status !== 'offline' : item.status === 'published'))
useSeoMeta({ title: () => `陪伴团队|${site.value.brand.name}`, description: () => `${site.value.brand.name}陪伴团队。` })
</script>
<template><PublicPageLayout :site="site"><section class="listing-hero"><p class="eyebrow">COMPANION TEAM</p><h1>陪伴团队</h1><p>以专业温暖和清晰的协作为每一段成长提供支持</p></section><section class="team-grid"><article v-for="member in members" :key="member.slug"><img :src="member.avatar" :alt="member.name" width="1536" height="1024" loading="lazy" decoding="async"><div><h2>{{ member.name }}</h2><p class="role">{{ member.role }}</p><p>{{ member.summary }}</p><div class="tag-list"><span v-for="tag in member.specialties" :key="tag">{{ tag }}</span></div></div></article></section></PublicPageLayout></template>

2
app/pages/terms.vue Normal file
View File

@@ -0,0 +1,2 @@
<script setup lang="ts">const { site } = await useSiteDocument(); useSeoMeta({ title: () => `${site.value.legal.termsTitle}${site.value.brand.name}` })</script>
<template><PublicPageLayout :site="site"><article class="legal-page"><p class="eyebrow">LEGAL</p><h1>{{ site.legal.termsTitle }}</h1><p class="long-copy">{{ site.legal.termsContent }}</p></article></PublicPageLayout></template>

9
app/utils/filing.ts Normal file
View File

@@ -0,0 +1,9 @@
export const MIIT_FILING_URL = 'https://beian.miit.gov.cn'
export const MPS_FILING_URL = 'https://beian.mps.gov.cn/'
export function getPoliceFilingUrl(record: string) {
const recordCode = record.match(/\d{12,20}/)?.[0]
return recordCode
? `https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=${recordCode}`
: MPS_FILING_URL
}