Files
guanwang/app/pages/admin/content.vue

150 lines
11 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { PhCheckCircle, PhFloppyDisk, PhPlus, PhTrash, PhUploadSimple } from '@phosphor-icons/vue'
import { apiErrorMessage } from '~/composables/useUnsavedChanges'
import type { SiteDocument } from '../../../server/utils/site-schema'
definePageMeta({ layout: false })
type ContentTab = 'products' | 'articles' | 'team'
const tab = ref<ContentTab>('products')
const selected = reactive({ products: 0, articles: 0, team: 0 })
const document = ref<SiteDocument>()
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 {
await $fetch('/api/auth/me')
document.value = (await $fetch<{ data: SiteDocument }>('/api/admin/site')).data
await nextTick()
markClean()
} catch { await navigateTo('/admin/login') }
finally { loading.value = false }
})
const currentProduct = computed(() => document.value?.products[selected.products])
const currentArticle = computed(() => document.value?.articles[selected.articles])
const currentMember = computed(() => document.value?.team[selected.team])
function makeSlug(prefix: string) { return `${prefix}-${Date.now().toString(36)}` }
function addItem() {
if (!document.value) return
if (tab.value === 'products') {
document.value.products.push({ slug: makeSlug('product'), title: '新产品', subtitle: '', type: 'course', summary: '', content: '', image: '/images/hero-bg-v3.webp', href: '', status: 'draft' })
selected.products = document.value.products.length - 1
} else if (tab.value === 'articles') {
document.value.articles.push({ slug: makeSlug('article'), type: 'news', title: '新内容', summary: '', content: '', cover: '/images/hero-bg-v3.webp', publishedAt: new Date().toISOString().slice(0, 10), status: 'draft', statementNo: '', sourceName: '', sourceUrl: '' })
selected.articles = document.value.articles.length - 1
} else {
document.value.team.push({ slug: makeSlug('member'), name: '新成员', role: '', summary: '', avatar: '/images/lu-hui-portrait.jpg', specialties: [], status: 'draft' })
selected.team = document.value.team.length - 1
}
}
function removeItem() {
if (!document.value || !window.confirm('确定删除当前内容吗?保存后生效。')) return
document.value[tab.value].splice(selected[tab.value], 1 as never)
selected[tab.value] = Math.max(0, selected[tab.value] - 1)
}
async function save(publish = false) {
if (!document.value) return
busy.value = true
message.value = ''
try {
await $fetch('/api/admin/site', { method: 'PUT', body: document.value })
if (publish) {
const result = await $fetch<{ version: number }>('/api/admin/site/publish', { method: 'POST' })
message.value = `已发布 V${result.version}`
} else message.value = '草稿已保存'
markClean()
} catch (error: any) { message.value = apiErrorMessage(error, '保存失败,请检查必填项和英文标识') }
finally { busy.value = false }
}
const previewHref = computed(() => {
if (tab.value === 'products' && currentProduct.value) return `/products/${currentProduct.value.slug}?preview=draft`
if (tab.value === 'articles' && currentArticle.value) return `${currentArticle.value.type === 'statement' ? '/statements' : '/news'}/${currentArticle.value.slug}?preview=draft`
return '/team?preview=draft'
})
async function uploadImage(event: Event, apply: (url: string) => void) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
busy.value = true
try {
const body = new FormData(); body.append('file', file)
const result = await $fetch<{ url: string }>('/api/admin/media', { method: 'POST', body })
apply(result.url); message.value = '图片已上传,请保存并发布'
} catch (error: any) { message.value = error?.data?.statusMessage || '图片上传失败' }
finally { busy.value = false; input.value = '' }
}
</script>
<template>
<main class="admin-shell">
<AdminSidebar active="content" :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"><PhCheckCircle :size="18" />{{ message }}</span>
<a :href="previewHref" target="_blank">预览草稿</a>
<button :disabled="busy" @click="save(false)"><PhFloppyDisk :size="18" />保存草稿</button>
<button class="publish-button" :disabled="busy" @click="save(true)"><PhUploadSimple :size="18" />发布更新</button>
</div>
</header>
<div v-if="loading" class="admin-loading">正在加载内容</div>
<div v-else-if="document" class="content-admin-body">
<div class="content-tabs">
<button v-for="item in ([['products','课程与产品'],['articles','动态与声明'],['team','陪伴团队']] as const)" :key="item[0]" :class="{ active: tab === item[0] }" @click="tab = item[0]">{{ item[1] }}</button>
</div>
<div class="content-layout">
<aside class="content-list">
<button class="add-content" @click="addItem"><PhPlus :size="18" />新建内容</button>
<template v-if="tab === 'products'">
<button v-for="(item, index) in document.products" :key="item.slug" :class="{ active: selected.products === index }" @click="selected.products = index"><strong>{{ item.title }}</strong><small>{{ item.status }} · {{ item.slug }}</small></button>
</template>
<template v-if="tab === 'articles'">
<button v-for="(item, index) in document.articles" :key="item.slug" :class="{ active: selected.articles === index }" @click="selected.articles = index"><strong>{{ item.title }}</strong><small>{{ item.type }} · {{ item.status }}</small></button>
</template>
<template v-if="tab === 'team'">
<button v-for="(item, index) in document.team" :key="item.slug" :class="{ active: selected.team === index }" @click="selected.team = index"><strong>{{ item.name }}</strong><small>{{ item.role || '未填写角色' }} · {{ item.status }}</small></button>
</template>
</aside>
<section class="content-editor">
<div class="panel-heading"><div><span>编辑</span><h1>{{ tab === 'products' ? '课程与产品' : tab === 'articles' ? '动态与声明' : '陪伴团队' }}</h1></div><button class="danger-link" :disabled="!(document[tab]?.length)" @click="removeItem"><PhTrash :size="17" />删除</button></div>
<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><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><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>
<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>
</section>
</div>
</div>
</section>
</main>
</template>