150 lines
11 KiB
Vue
150 lines
11 KiB
Vue
<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 })
|
||
useHead(() => ({ title: `内容管理|${document.value?.brand.name || '慧遇书院'}` }))
|
||
|
||
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)
|
||
|
||
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><label class="full">正文<textarea v-model="currentProduct.content" rows="12" /></label>
|
||
<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>来源名称<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>
|
||
<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>
|