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

@@ -15,6 +15,7 @@
}
* { box-sizing: border-box; }
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
html { scroll-behavior: smooth; background: var(--paper); }
body { margin: 0; color: var(--ink); font-family: var(--sans); font-size: 16px; line-height: 1.65; background: var(--paper); -webkit-font-smoothing: antialiased; }
body.nav-open { overflow: hidden; }
@@ -40,9 +41,11 @@ img { max-width: 100%; }
.section-heading.centered { max-width: 780px; margin: 0 auto 56px; text-align: center; }
.site-header { position: absolute; z-index: 20; top: 0; left: 0; display: grid; grid-template-columns: 250px 1fr 160px; align-items: center; width: 100%; padding: 26px 4.2vw; }
.main-nav { display: flex; justify-content: center; gap: clamp(24px, 3.2vw, 54px); }
.main-nav a { position: relative; display: inline-flex; align-items: center; min-height: 44px; padding: 0 4px; color: #17364e; font-size: 14px; white-space: nowrap; }
.main-nav a::after { position: absolute; right: 50%; bottom: -12px; left: 50%; height: 1px; content: ""; background: var(--ink); transition: .28s ease; }
.main-nav { display: flex; justify-content: center; gap: clamp(18px, 2.4vw, 42px); }
.main-nav a { position: relative; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; min-height: 54px; padding: 0 3px; color: #17364e; white-space: nowrap; }
.nav-label { font-size: 14px; line-height: 1.2; }
.nav-english { margin-top: 5px; color: #71889b; font-size: 9px; font-weight: 500; line-height: 1; letter-spacing: .12em; }
.main-nav a::after { position: absolute; right: 50%; bottom: -8px; left: 50%; height: 1px; content: ""; background: var(--ink); transition: .28s ease; }
.main-nav a:hover::after, .main-nav a:focus-visible::after, .main-nav a[aria-current="page"]::after { right: 0; left: 0; }
.header-cta, .primary-button { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 11px; overflow: hidden; border-radius: 999px; color: white; background: linear-gradient(135deg, #2b5f8b, #0a3456); box-shadow: 0 12px 28px rgba(10, 52, 86, .2); isolation: isolate; }
.header-cta::before, .primary-button::before { position: absolute; top: -80%; left: -45%; z-index: -1; width: 32%; height: 260%; content: ""; background: linear-gradient(90deg, transparent, rgba(255,255,255,.32), transparent); transform: rotate(20deg) translateX(-260%); transition: transform .65s cubic-bezier(.2,.7,.2,1); }
@@ -187,6 +190,20 @@ img { max-width: 100%; }
.story-body .lead { margin-bottom: 30px; color: #315a7a; font: 400 22px/1.8 var(--serif); }
.long-copy { color: #516b80; font-size: 16px; line-height: 2.15; white-space: pre-line; }
.story-body .primary-button { min-height: 50px; margin-top: 38px; padding: 0 28px; }
.rich-text-content { color: #516b80; font-size: 16px; line-height: 2.15; overflow-wrap: anywhere; }
.rich-text-content > :first-child { margin-top: 0; }
.rich-text-content > :last-child { margin-bottom: 0; }
.rich-text-content p { margin: 0 0 1.2em; }
.rich-text-content h2, .rich-text-content h3 { color: var(--ink); font-family: var(--serif); font-weight: 500; line-height: 1.45; }
.rich-text-content h2 { margin: 1.7em 0 .7em; font-size: 30px; }
.rich-text-content h3 { margin: 1.5em 0 .6em; font-size: 23px; }
.rich-text-content ul, .rich-text-content ol { margin: 1.1em 0; padding-left: 1.7em; }
.rich-text-content li { margin: .45em 0; }
.rich-text-content blockquote { margin: 1.5em 0; padding: 15px 22px; border-left: 3px solid #7b9ab2; color: #426782; background: #f3f7fa; }
.rich-text-content a { color: #286895; text-decoration: underline; text-underline-offset: 3px; }
.rich-text-content hr { margin: 2em 0; border: 0; border-top: 1px solid var(--line); }
.rich-text-content img { display: block; max-width: 100%; height: auto; margin: 1.4em 0; border-radius: 10px; }
.rich-text-content pre { overflow-x: auto; padding: 16px 18px; border-radius: 10px; color: #edf4f8; background: #17364e; font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; }
.statement-page { max-width: 900px; }
.statement-page header { padding: 66px 30px; border-radius: 34px; background: rgba(255,255,255,.7); box-shadow: var(--shadow); }
.detail-hero { display: grid; grid-template-columns: 1.05fr .95fr; align-items: center; max-width: 1260px; min-height: 620px; margin: 0 auto; padding: 54px 32px 80px; }
@@ -306,6 +323,33 @@ img { max-width: 100%; }
.content-list > button small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.content-list .add-content { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 5px; border: 1px dashed #aac0d2; color: #35698f; }
.content-editor { padding: 28px; }
.field-group { display: grid; gap: 8px; color: #38566e; font-size: 13px; }
.form-grid .field-group.full { grid-column: 1 / 3; }
.rich-text-editor { border: 1px solid #d5dfe7; border-radius: 12px; background: #fbfcfd; }
.rich-text-editor:focus-within { border-color: #6d96b7; box-shadow: 0 0 0 3px rgba(78,128,168,.08); }
.rich-text-toolbar { position: sticky; z-index: 5; top: 95px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 10px; border-bottom: 1px solid #dbe4eb; border-radius: 11px 11px 0 0; background: #f4f7fa; }
.rich-text-toolbar button, .rich-text-toolbar select, .toolbar-color { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 10px; border: 1px solid #cfdbe5; border-radius: 7px; color: #294d68; background: white; font-size: 12px; cursor: pointer; }
.rich-text-toolbar select { width: auto; max-width: 160px; }
.rich-text-toolbar button { min-width: 34px; }
.rich-text-toolbar button:hover, .rich-text-toolbar button.active, .toolbar-color:hover { border-color: #7396b3; color: #164d77; background: #e4eef7; }
.rich-text-toolbar button:disabled { opacity: .4; cursor: not-allowed; }
.toolbar-divider { width: 1px; height: 25px; margin: 0 2px; background: #cfdbe5; }
.toolbar-color { position: relative; width: 34px; padding: 0; border-bottom: 4px solid #17364e; font-weight: 700; }
.toolbar-color.highlight { border-bottom-color: #fff1c9; background: #fff9e8; }
.toolbar-color input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.rich-text-editor .tiptap { min-height: 420px; padding: 24px; outline: 0; color: #26465f; font: 400 16px/1.85 var(--sans); }
.rich-text-editor .tiptap > :first-child { margin-top: 0; }
.rich-text-editor .tiptap h2, .rich-text-editor .tiptap h3 { font-family: var(--serif); font-weight: 500; }
.rich-text-editor .tiptap h2 { font-size: 28px; }
.rich-text-editor .tiptap h3 { font-size: 22px; }
.rich-text-editor .tiptap blockquote { margin: 18px 0; padding: 12px 18px; border-left: 3px solid #7b9ab2; background: #edf3f7; }
.rich-text-editor .tiptap a { color: #286895; text-decoration: underline; }
.rich-text-editor .tiptap img { display: block; max-width: 100%; height: auto; margin: 18px 0; border-radius: 10px; }
.rich-text-editor .tiptap pre { overflow-x: auto; margin: 18px 0; padding: 16px 18px; border-radius: 10px; color: #edf4f8; background: #17364e; font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; }
.rich-text-editor .tiptap hr { margin: 24px 0; border: 0; border-top: 1px solid #cbd8e2; }
.rich-text-editor .tiptap p.is-editor-empty:first-child::before { float: left; height: 0; color: #98aaba; content: "在此输入正文,可像 Word 一样选择文字后设置格式…"; pointer-events: none; }
.rich-text-message { margin: 0; padding: 8px 12px; color: #a84d43; background: #fff3f1; font-size: 12px; }
.rich-text-footer { display: flex; justify-content: space-between; gap: 20px; padding: 9px 12px; border-top: 1px solid #e2e9ef; color: #7890a3; font-size: 11px; }
.danger-link, .inline-add { display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px; border: 0; border-radius: 8px; color: #9b4b43; background: #fff1ef; cursor: pointer; }
.inline-add { color: #35698f; background: #e9f1f8; }
.form-grid select { width: 100%; padding: 12px 13px; border: 1px solid #d5dfe7; border-radius: 9px; color: #1c3c56; background: #fbfcfd; }
@@ -380,12 +424,14 @@ img { max-width: 100%; }
@keyframes panel-wipe { from { opacity: .4; clip-path: inset(0 100% 0 0 round 38px); transform: translateY(22px); } to { opacity: 1; clip-path: inset(0 0 0 0 round 38px); transform: none; } }
@keyframes float-cue { 0%,100% { transform: translate(-50%, 0); } 50% { transform: translate(-50%, 7px); } }
@media (max-width: 1100px) {
@media (max-width: 1300px) {
.site-header { position: fixed; grid-template-columns: 1fr auto; min-height: 72px; padding: 12px 24px; border-bottom: 1px solid rgba(28,62,88,.08); background: rgba(248,250,252,.9); backdrop-filter: blur(18px); }
.menu-toggle { z-index: 3; display: inline-flex; padding: 10px; cursor: pointer; }
.main-nav { position: fixed; top: 0; left: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100vw; height: 100svh; gap: 28px; visibility: hidden; opacity: 0; background: rgba(247,249,251,.97); transition: visibility .28s ease, opacity .28s ease; }
.main-nav { position: fixed; top: 0; left: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100vw; height: 100svh; gap: 20px; visibility: hidden; opacity: 0; background: rgba(247,249,251,.97); transition: visibility .28s ease, opacity .28s ease; }
.main-nav.open { visibility: visible; opacity: 1; }
.main-nav a { display: flex; min-width: 180px; min-height: 48px; justify-content: center; font: 500 24px var(--serif); }
.main-nav a { display: flex; min-width: 220px; min-height: 56px; justify-content: center; }
.main-nav .nav-label { font: 500 23px/1.15 var(--serif); }
.main-nav .nav-english { margin-top: 6px; font: 500 9px/1 var(--sans); letter-spacing: .18em; }
.header-cta { display: none; }
.teacher-card { grid-template-columns: .9fr 1.1fr; }
.official-grid { grid-template-columns: repeat(2, 1fr); }
@@ -482,6 +528,10 @@ img { max-width: 100%; }
.editor-panel { padding: 20px 16px; }
.form-grid { grid-template-columns: 1fr; }
.form-grid label.full { grid-column: 1; }
.form-grid .field-group.full { grid-column: 1; }
.rich-text-toolbar { gap: 5px; }
.rich-text-toolbar select { max-width: 118px; }
.rich-text-editor .tiptap { min-height: 340px; padding: 18px; }
}
@media (max-width: 520px) {

View File

@@ -0,0 +1,10 @@
<script setup lang="ts">
import { toDisplayHtml } from '~/utils/rich-text'
const props = defineProps<{ content: string }>()
const html = computed(() => toDisplayHtml(props.content))
</script>
<template>
<div class="rich-text-content" v-html="html" />
</template>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { TextAlign } from '@tiptap/extension-text-align'
import { TextStyleKit } from '@tiptap/extension-text-style'
import Image from '@tiptap/extension-image'
import StarterKit from '@tiptap/starter-kit'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import { toEditorHtml } from '~/utils/rich-text'
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const linkMessage = ref('')
const imageInput = ref<HTMLInputElement>()
const imageMessage = ref('')
const imageBusy = ref(false)
const editor = useEditor({
content: toEditorHtml(props.modelValue),
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
link: {
openOnClick: false,
autolink: true,
HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' },
},
}),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TextStyleKit,
Image.configure({ allowBase64: false }),
],
editorProps: {
attributes: {
class: 'rich-text-surface',
'aria-label': '正文编辑区域',
spellcheck: 'true',
},
},
onUpdate: ({ editor: instance }) => {
emit('update:modelValue', instance.isEmpty ? '' : instance.getHTML())
},
})
watch(() => props.modelValue, (value) => {
if (!editor.value) return
const next = toEditorHtml(value)
if (editor.value.getHTML() !== next) editor.value.commands.setContent(next, { emitUpdate: false })
})
function selectBlock(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value === 'paragraph') editor.value?.chain().focus().setParagraph().run()
else editor.value?.chain().focus().toggleHeading({ level: Number(value) as 2 | 3 }).run()
}
function selectFontFamily(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value) editor.value?.chain().focus().setFontFamily(value).run()
else editor.value?.chain().focus().unsetFontFamily().run()
}
function selectFontSize(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value) editor.value?.chain().focus().setFontSize(value).run()
else editor.value?.chain().focus().unsetFontSize().run()
}
function selectLineHeight(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value) editor.value?.chain().focus().setLineHeight(value).run()
else editor.value?.chain().focus().unsetLineHeight().run()
}
function setColor(event: Event, background = false) {
const value = (event.target as HTMLInputElement).value
const chain = editor.value?.chain().focus()
if (background) chain?.setBackgroundColor(value).run()
else chain?.setColor(value).run()
}
function setLink() {
const previous = editor.value?.getAttributes('link').href || ''
const href = window.prompt('请输入链接地址http、https、mailto 或 tel', previous)
if (href === null) return
if (!href.trim()) {
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
if (!/^(https?:\/\/|mailto:|tel:)/i.test(href.trim())) {
linkMessage.value = '链接需以 http://、https://、mailto: 或 tel: 开头'
return
}
linkMessage.value = ''
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: href.trim(), target: '_blank', rel: 'noopener noreferrer' }).run()
}
function insertImageUrl() {
const src = window.prompt('请输入图片地址(建议使用素材库中的图片地址)')
if (!src?.trim()) return
if (!/^https?:\/\//i.test(src.trim()) && !src.trim().startsWith('/')) {
imageMessage.value = '图片地址需以 http://、https:// 或 / 开头'
return
}
imageMessage.value = ''
editor.value?.chain().focus().setImage({ src: src.trim() }).run()
}
async function uploadImage(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
imageBusy.value = true
imageMessage.value = ''
try {
const body = new FormData()
body.append('file', file)
const result = await $fetch<{ url: string }>('/api/admin/media', { method: 'POST', body })
editor.value?.chain().focus().setImage({ src: result.url, alt: file.name }).run()
} catch (error: any) {
imageMessage.value = error?.data?.statusMessage || '图片上传失败,请检查图片格式和大小'
} finally {
imageBusy.value = false
;(event.target as HTMLInputElement).value = ''
}
}
</script>
<template>
<div class="rich-text-editor">
<div v-if="editor" class="rich-text-toolbar" role="toolbar" aria-label="正文排版工具栏">
<select aria-label="段落样式" title="段落样式" @change="selectBlock">
<option value="paragraph">正文</option>
<option value="2">二级标题</option>
<option value="3">三级标题</option>
</select>
<select aria-label="字体" title="字体" @change="selectFontFamily">
<option value="">默认字体</option>
<option value="Noto Serif SC, serif">宋体风格</option>
<option value="Noto Sans SC, sans-serif">黑体风格</option>
<option value="Arial, sans-serif">Arial</option>
</select>
<select aria-label="字号" title="字号" @change="selectFontSize">
<option value="">默认字号</option>
<option v-for="size in [14,16,18,20,24,28,32]" :key="size" :value="`${size}px`">{{ size }} px</option>
</select>
<select aria-label="行距" title="行距" @change="selectLineHeight">
<option value="">默认行距</option>
<option value="1.5">1.5 </option>
<option value="1.75">1.75 </option>
<option value="2">2 </option>
<option value="2.25">2.25 </option>
</select>
<span class="toolbar-divider" />
<button type="button" :class="{ active: editor.isActive('bold') }" title="加粗" aria-label="加粗" @click="editor.chain().focus().toggleBold().run()"><b>B</b></button>
<button type="button" :class="{ active: editor.isActive('italic') }" title="斜体" aria-label="斜体" @click="editor.chain().focus().toggleItalic().run()"><i>I</i></button>
<button type="button" :class="{ active: editor.isActive('underline') }" title="下划线" aria-label="下划线" @click="editor.chain().focus().toggleUnderline().run()"><u>U</u></button>
<button type="button" :class="{ active: editor.isActive('strike') }" title="删除线" aria-label="删除线" @click="editor.chain().focus().toggleStrike().run()"><s>S</s></button>
<label class="toolbar-color" title="文字颜色">A<input type="color" value="#17364e" aria-label="文字颜色" @input="setColor($event)"></label>
<label class="toolbar-color highlight" title="背景颜色">A<input type="color" value="#fff1c9" aria-label="背景颜色" @input="setColor($event, true)"></label>
<span class="toolbar-divider" />
<button type="button" :class="{ active: editor.isActive({ textAlign: 'left' }) }" title="左对齐" aria-label="左对齐" @click="editor.chain().focus().setTextAlign('left').run()"></button>
<button type="button" :class="{ active: editor.isActive({ textAlign: 'center' }) }" title="居中对齐" aria-label="居中对齐" @click="editor.chain().focus().setTextAlign('center').run()"></button>
<button type="button" :class="{ active: editor.isActive({ textAlign: 'right' }) }" title="右对齐" aria-label="右对齐" @click="editor.chain().focus().setTextAlign('right').run()"></button>
<button type="button" :class="{ active: editor.isActive({ textAlign: 'justify' }) }" title="两端对齐" aria-label="两端对齐" @click="editor.chain().focus().setTextAlign('justify').run()"></button>
<button type="button" :class="{ active: editor.isActive('bulletList') }" title="项目符号" aria-label="项目符号" @click="editor.chain().focus().toggleBulletList().run()"> 列表</button>
<button type="button" :class="{ active: editor.isActive('orderedList') }" title="编号列表" aria-label="编号列表" @click="editor.chain().focus().toggleOrderedList().run()">1. 列表</button>
<button type="button" :class="{ active: editor.isActive('blockquote') }" title="引用" aria-label="引用" @click="editor.chain().focus().toggleBlockquote().run()">引用</button>
<button type="button" :class="{ active: editor.isActive('link') }" title="添加或编辑链接" aria-label="添加或编辑链接" @click="setLink">链接</button>
<button type="button" title="插入图片地址" aria-label="插入图片地址" @click="insertImageUrl">图片地址</button>
<button type="button" title="上传图片" aria-label="上传图片" :disabled="imageBusy" @click="imageInput?.click()">{{ imageBusy ? '上传中' : '上传图片' }}</button>
<input ref="imageInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp,image/gif" @change="uploadImage">
<button type="button" :class="{ active: editor.isActive('codeBlock') }" title="代码块" aria-label="代码块" @click="editor.chain().focus().toggleCodeBlock().run()">代码</button>
<button type="button" title="分隔线" aria-label="分隔线" @click="editor.chain().focus().setHorizontalRule().run()">分隔线</button>
<button type="button" title="清除格式" aria-label="清除格式" @click="editor.chain().focus().clearNodes().unsetAllMarks().run()">清除格式</button>
<button type="button" title="撤销" aria-label="撤销" :disabled="!editor.can().undo()" @click="editor.chain().focus().undo().run()"></button>
<button type="button" title="重做" aria-label="重做" :disabled="!editor.can().redo()" @click="editor.chain().focus().redo().run()"></button>
</div>
<EditorContent :editor="editor" />
<p v-if="linkMessage" class="rich-text-message">{{ linkMessage }}</p>
<p v-if="imageMessage" class="rich-text-message">{{ imageMessage }}</p>
<div class="rich-text-footer"><span>支持从 Word 粘贴发布时会自动清理不安全代码</span><span>{{ modelValue.length }} 字符</span></div>
</div>
</template>

View File

@@ -6,16 +6,17 @@ const toggle = ref<HTMLButtonElement>()
const navigation = ref<HTMLElement>()
const props = withDefaults(defineProps<{ logo?: string; brandName?: string; englishName?: string }>(), {
brandName: '慧遇书院',
englishName: 'HUIYU ACADEMY',
})
const route = useRoute()
const links = computed(() => [
{ label: '首页', href: '/#home' },
{ label: `关于${props.brandName}`, href: '/#about' },
{ label: '卢慧老师', href: '/luhui' },
{ label: '课程与产品', href: '/products' },
{ label: '官方信息', href: '/#official' },
{ label: '品牌动态', href: '/news' },
{ label: '联系我们', href: '/#contact' },
{ label: '首页', englishLabel: 'HOME', href: '/#home' },
{ label: `关于${props.brandName}`, englishLabel: `ABOUT ${props.englishName}`, href: '/#about' },
{ label: '卢慧老师', englishLabel: 'LU HUI', href: '/luhui' },
{ label: '课程与产品', englishLabel: 'COURSES & PRODUCTS', href: '/products' },
{ label: '官方信息', englishLabel: 'OFFICIAL INFO', href: '/#official' },
{ label: '品牌动态', englishLabel: 'BRAND NEWS', href: '/news' },
{ label: '联系我们', englishLabel: 'CONTACT US', href: '/#contact' },
])
function closeMenu(returnFocus = false) {
@@ -47,7 +48,10 @@ onBeforeUnmount(() => {
<List v-else :size="24" />
</button>
<nav id="site-navigation" ref="navigation" :class="['main-nav', { open }]" aria-label="主导航" @keydown.esc="closeMenu(true)">
<a v-for="link in links" :key="link.href" :href="link.href" :aria-current="isActive(link.href) ? 'page' : undefined" @click="closeMenu()">{{ link.label }}</a>
<a v-for="link in links" :key="link.href" :href="link.href" :aria-current="isActive(link.href) ? 'page' : undefined" @click="closeMenu()">
<span class="nav-label">{{ link.label }}</span>
<span class="nav-english" lang="en">{{ link.englishLabel }}</span>
</a>
</nav>
<a class="header-cta" href="/#official">
官方渠道

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>

24
app/utils/rich-text.ts Normal file
View File

@@ -0,0 +1,24 @@
const RICH_TEXT_TAG = /<(?:p|h[1-6]|ul|ol|li|blockquote|strong|em|u|s|a|br|hr|span)\b/i
export function escapeRichText(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
}
export function toEditorHtml(value: string) {
if (!value.trim()) return '<p></p>'
if (RICH_TEXT_TAG.test(value)) return value
return value
.split(/\n{2,}/)
.map(paragraph => `<p>${escapeRichText(paragraph).replaceAll('\n', '<br>')}</p>`)
.join('')
}
export function toDisplayHtml(value: string) {
return toEditorHtml(value || '')
}