feat: 完善内容管理通用富文本编辑器

This commit is contained in:
2026-08-27 17:01:48 +08:00
parent ed81f24892
commit d93486cd9a
21 changed files with 1245 additions and 30 deletions

View File

@@ -4,7 +4,6 @@ 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')
@@ -14,6 +13,7 @@ const loading = ref(true)
const busy = ref(false)
const message = ref('')
const { dirty, markClean } = useUnsavedChanges(document)
useHead(() => ({ title: `内容管理|${document.value?.brand.name || '慧遇书院'}` }))
onMounted(async () => {
try {
@@ -123,21 +123,21 @@ async function uploadImage(event: Event, apply: (url: string) => void) {
<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.subtitle"></label><label class="full">摘要<textarea v-model="currentProduct.summary" rows="3" /></label><div class="full field-group"><span>正文</span><RichTextEditor v-model="currentProduct.content" /></div>
<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 class="full">摘要<textarea v-model="currentArticle.summary" rows="3" /></label><div class="full field-group"><span>正文</span><RichTextEditor v-model="currentArticle.content" /></div>
<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>
<div class="full field-group"><span>简介</span><RichTextEditor v-model="currentMember.summary" /></div><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>

View File

@@ -10,7 +10,6 @@ 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)
@@ -20,6 +19,7 @@ const document = ref<SiteDocument>()
const user = ref<{ displayName: string }>()
const uploadBusy = ref<string>()
const { dirty, markClean } = useUnsavedChanges(document)
useHead(() => ({ title: `首页管理|${document.value?.brand.name || '慧遇书院'}` }))
onMounted(async () => {
try {

View File

@@ -4,7 +4,6 @@ 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 }>>([])
@@ -13,6 +12,7 @@ type HealthStatus = { status: string; version: string; checks: { database: strin
const health = ref<HealthStatus>()
const passwords = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' })
const { dirty, markClean } = useUnsavedChanges(document)
useHead(() => ({ title: `网站设置|${document.value?.brand.name || '慧遇书院'}` }))
onMounted(async () => {
try {
await $fetch('/api/auth/me')

View File

@@ -19,14 +19,24 @@ 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 defaultHeroImage = '/images/hero-bg-v3.webp'
const heroImageSource = ref(site.value.hero.backgroundImage || defaultHeroImage)
const hero = ref<HTMLElement>()
let heroFrame = 0
const absoluteHero = computed(() => absoluteUrl(site.value.hero.backgroundImage))
const absoluteHero = computed(() => absoluteUrl(heroImageSource.value))
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 usesDefaultHero = computed(() => heroImageSource.value === defaultHeroImage)
const previewTo = (path: string) => isPreview.value ? { path, query: { preview: 'draft' } } : path
watch(() => site.value.hero.backgroundImage, (value) => {
heroImageSource.value = value || defaultHeroImage
})
function useHeroFallback() {
if (heroImageSource.value !== defaultHeroImage) heroImageSource.value = defaultHeroImage
}
useSeoMeta({
title: () => site.value?.seo.title,
description: () => site.value?.seo.description,
@@ -78,7 +88,7 @@ onBeforeUnmount(() => {
<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">
<img :src="heroImageSource" alt="" width="1672" height="941" fetchpriority="high" decoding="async" @error="useHeroFallback">
</picture>
<SiteHeader :logo="site.brand.logo" :brand-name="site.brand.name" :english-name="site.brand.englishName" />
<div class="hero-copy">

View File

@@ -6,4 +6,4 @@ 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>
<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><RichTextContent :content="article.content" /><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>

View File

@@ -7,4 +7,4 @@ 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>
<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><RichTextContent :content="product.content" /><a class="primary-button article-consult" href="/#contact">咨询了解</a></div></article></PublicPageLayout></template>

View File

@@ -4,4 +4,4 @@ const article = computed(() => site.value.articles.find(item => item.slug === ro
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>
<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><RichTextContent :content="article.content" /></div></article></PublicPageLayout></template>

View File

@@ -3,4 +3,4 @@ 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>
<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><RichTextContent :content="member.summary" /><div class="tag-list"><span v-for="tag in member.specialties" :key="tag">{{ tag }}</span></div></div></article></section></PublicPageLayout></template>