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

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>