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

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>