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

77 lines
10 KiB
Vue
Raw 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 })
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 }>>([])
const auditLogs = ref<Array<{ id: number; action: string; entityType: string; entityKey: string | null; ipAddress: string | null; createdAt: string; operator: string }>>([])
type HealthStatus = { status: string; version: string; checks: { database: string; uploads: string; freeBytes: number } }
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')
const [siteResult, revisionResult, auditResult, healthResult] = await Promise.all([$fetch<{ data: SiteDocument }>('/api/admin/site'), $fetch<{ data: typeof revisions.value }>('/api/admin/site/revisions'), $fetch<{ data: typeof auditLogs.value }>('/api/admin/audit'), $fetch<HealthStatus>('/api/health')])
document.value = siteResult.data; revisions.value = revisionResult.data; auditLogs.value = auditResult.data; health.value = healthResult
await nextTick(); markClean()
}
catch { await navigateTo('/admin/login') }
finally { loading.value = false }
})
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 }
}
async function restoreVersion(version: number) {
if (!window.confirm(`确定把 V${version} 恢复为当前草稿吗?已发布网站不会立即变化。`)) return
busy.value = true
try { document.value = (await $fetch<{ data: SiteDocument }>(`/api/admin/site/revisions/${version}/restore`, { method: 'POST' })).data; await nextTick(); markClean(); message.value = `V${version} 已恢复为草稿,请检查后再发布` }
catch (error: any) { message.value = error?.data?.statusMessage || '恢复失败' }
finally { busy.value = false }
}
async function changePassword() {
if (passwords.newPassword !== passwords.confirmPassword) { message.value = '两次输入的新密码不一致'; return }
busy.value = true
try {
await $fetch('/api/auth/password', { method: 'PUT', body: { currentPassword: passwords.currentPassword, newPassword: passwords.newPassword } })
passwords.currentPassword = ''; passwords.newPassword = ''; passwords.confirmPassword = ''; message.value = '管理员密码已修改'
} catch (error: any) { message.value = error?.data?.statusMessage || error?.data?.data?.issues?.[0]?.message || '密码修改失败' }
finally { busy.value = false }
}
</script>
<template>
<main class="admin-shell"><AdminSidebar active="settings" :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="/luhui?preview=draft" 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="settings-admin-body">
<section class="editor-panel"><div class="panel-heading"><div><span>01</span><h1>公司主体信息</h1></div><small>正式上线前必须核验</small></div><div class="form-grid">
<label>官网展示名称<input v-model="document.brand.name" placeholder="例如:慧遇书院"></label><label>公司主体全称<input v-model="document.brand.companyName" placeholder="营业执照登记名称"></label><label>统一社会信用代码<input v-model="document.brand.creditCode"></label><label>版权信息<input v-model="document.brand.copyright"></label><label>ICP备案<input v-model="document.brand.icp"></label><label>公安备案<input v-model="document.brand.policeRecord"></label><label class="full">公司介绍<textarea v-model="document.brand.introduction" rows="5" /></label>
</div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>02</span><h2>卢慧老师完整资料</h2></div><small>请以本人确认内容为准</small></div><div class="form-grid">
<label class="full">完整介绍<textarea v-model="document.teacher.fullBio" rows="12" /></label><label class="full">标签(用中文逗号分隔)<input :value="document.teacher.tags.join('')" @input="document.teacher.tags = ($event.target as HTMLInputElement).value.split(/[,]/).map(v => v.trim()).filter(Boolean).slice(0, 6)"></label><label class="full">代表成果(每行一项)<textarea :value="document.teacher.achievements.join('\n')" rows="6" @input="document.teacher.achievements = ($event.target as HTMLTextAreaElement).value.split('\n').map(v => v.trim()).filter(Boolean).slice(0, 12)" /></label>
</div><div class="subsection-heading"><h3>经历与实践</h3><button class="inline-add" @click="document.teacher.experiences.push({ year: '', title: '新经历', description: '' })"><PhPlus :size="17" />新增经历</button></div><div class="repeat-settings"><article v-for="(experience, index) in document.teacher.experiences" :key="index"><div class="form-grid"><label>年份<input v-model="experience.year"></label><label>标题<input v-model="experience.title"></label><label class="full">说明<textarea v-model="experience.description" rows="3" /></label></div><button class="danger-link" @click="document.teacher.experiences.splice(index, 1)"><PhTrash :size="16" />移除</button></article></div><div class="subsection-heading"><h3>外部权威链接</h3><button class="inline-add" @click="document.teacher.externalLinks.push({ label: '新链接', url: '' })"><PhPlus :size="17" />新增链接</button></div><div class="repeat-settings"><article v-for="(link, index) in document.teacher.externalLinks" :key="index"><div class="form-grid"><label>名称<input v-model="link.label"></label><label>链接<input v-model="link.url"></label></div><button class="danger-link" @click="document.teacher.externalLinks.splice(index, 1)"><PhTrash :size="16" />移除</button></article></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>03</span><h2>官方渠道</h2></div><button class="inline-add" @click="document.channels.push({ name: '新渠道', account: '', url: '', qrCode: '' })"><PhPlus :size="17" />新增渠道</button></div><div class="repeat-settings"><article v-for="(channel, index) in document.channels" :key="index"><div class="form-grid"><label>渠道名称<input v-model="channel.name"></label><label>账号<input v-model="channel.account"></label><label>跳转链接<input v-model="channel.url"></label><label>二维码图片地址<input v-model="channel.qrCode"><MediaPicker v-model="channel.qrCode" label="从素材库选择二维码" /></label></div><button class="danger-link" @click="document.channels.splice(index, 1)"><PhTrash :size="16" />移除</button></article></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>04</span><h2>法律文本</h2></div><small>纯文本安全展示</small></div><div class="form-grid"><label>隐私政策标题<input v-model="document.legal.privacyTitle"></label><label>服务条款标题<input v-model="document.legal.termsTitle"></label><label class="full">隐私政策<textarea v-model="document.legal.privacyContent" rows="12" /></label><label class="full">服务条款<textarea v-model="document.legal.termsContent" rows="12" /></label></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>05</span><h2>发布版本</h2></div><small>最近 20 个发布快照</small></div><div class="revision-list"><article v-for="revision in revisions" :key="revision.version"><div><strong>V{{ revision.version }}</strong><small>{{ new Date(revision.createdAt).toLocaleString('zh-CN') }}</small></div><button :disabled="busy" @click="restoreVersion(revision.version)">恢复为草稿</button></article><p v-if="!revisions.length">尚无发布历史</p></div></section>
<section class="editor-panel"><div class="panel-heading"><div><span>06</span><h2>管理员密码</h2></div><small>至少 12 包含大小写数字和特殊字符</small></div><form class="form-grid" @submit.prevent="changePassword"><label>当前密码<input v-model="passwords.currentPassword" type="password" autocomplete="current-password"></label><label>新密码<input v-model="passwords.newPassword" type="password" autocomplete="new-password"></label><label>确认新密码<input v-model="passwords.confirmPassword" type="password" autocomplete="new-password"></label><div class="password-action"><button class="inline-add" :disabled="busy">修改密码</button></div></form></section>
<section class="editor-panel"><div class="panel-heading"><div><span>07</span><h2>系统状态与操作记录</h2></div><small v-if="health" class="health-ok">运行正常 · {{ health.version }} · 可用空间 {{ (health.checks.freeBytes / 1024 / 1024 / 1024).toFixed(1) }} GB</small></div><div class="audit-list"><article v-for="log in auditLogs.slice(0, 20)" :key="log.id"><div><strong>{{ log.action }}</strong><span>{{ log.entityType }}<template v-if="log.entityKey"> · {{ log.entityKey }}</template></span></div><small>{{ log.operator }} · {{ new Date(log.createdAt).toLocaleString('zh-CN') }}</small></article><p v-if="!auditLogs.length">暂无操作记录</p></div></section>
</div>
</section>
</main>
</template>