177 lines
9.0 KiB
Vue
177 lines
9.0 KiB
Vue
<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>
|