feat: 完善内容管理通用富文本编辑器
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user