增加更多可配置的部分
This commit is contained in:
27
client/src/components/HomeBentoPlaceholder.vue
Normal file
27
client/src/components/HomeBentoPlaceholder.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div
|
||||
class="art-card rounded-2xl pointer-events-none relative overflow-hidden !border-dashed !border-art-border"
|
||||
:class="span === 2 ? 'md:col-span-2' : ''"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="absolute inset-0 opacity-40">
|
||||
<div class="absolute top-6 left-6 w-10 h-px bg-art-accent/40"></div>
|
||||
<div class="absolute top-6 left-6 w-px h-10 bg-art-accent/40"></div>
|
||||
<div class="absolute bottom-6 right-6 w-10 h-px bg-art-accent/30"></div>
|
||||
<div class="absolute bottom-6 right-6 w-px h-10 bg-art-accent/30"></div>
|
||||
</div>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 px-6">
|
||||
<div class="w-12 h-px bg-gradient-to-r from-transparent via-art-accent/50 to-transparent"></div>
|
||||
<span class="font-mono text-[10px] tracking-[0.35em] uppercase text-art-muted/50">···</span>
|
||||
<div class="w-12 h-px bg-gradient-to-r from-transparent via-art-accent/50 to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
span?: 1 | 2
|
||||
}>(), {
|
||||
span: 1,
|
||||
})
|
||||
</script>
|
||||
353
client/src/components/admin/HomepageConfigForm.vue
Normal file
353
client/src/components/admin/HomepageConfigForm.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<!-- Hero -->
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-art-accent uppercase tracking-wider">Hero 文案</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="space-y-1 md:col-span-2">
|
||||
<span class="text-xs text-art-muted">副标题 / Eyebrow</span>
|
||||
<input v-model="model.hero.eyebrow" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">标题行 1</span>
|
||||
<input v-model="model.hero.titleLines[0]" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">标题行 2</span>
|
||||
<input v-model="model.hero.titleLines[1]" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1 md:col-span-2">
|
||||
<span class="text-xs text-art-muted">标题行 3</span>
|
||||
<input v-model="model.hero.titleLines[2]" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1 md:col-span-2">
|
||||
<span class="text-xs text-art-muted">简介前文</span>
|
||||
<input v-model="model.hero.bioBefore" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">高亮名称</span>
|
||||
<input v-model="model.hero.bioName" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">强调短语</span>
|
||||
<input v-model="model.hero.bioEmphasize" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1 md:col-span-2">
|
||||
<span class="text-xs text-art-muted">简介中间文案</span>
|
||||
<input v-model="model.hero.bioMid" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">CTA 文案</span>
|
||||
<input v-model="model.hero.ctaLabel" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">CTA 链接</span>
|
||||
<input v-model="model.hero.ctaLink" class="admin-input w-full" placeholder="/works" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.hero.ctaClickable" type="checkbox" class="accent-art-accent" />
|
||||
CTA 可点击跳转
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Latest work card -->
|
||||
<section class="space-y-4 pt-4 border-t border-white/5">
|
||||
<h3 class="text-sm font-medium text-art-accent uppercase tracking-wider">最新作品卡</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.latestWorkCard.visible" type="checkbox" class="accent-art-accent" />
|
||||
展示
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.latestWorkCard.clickable" type="checkbox" class="accent-art-accent" />
|
||||
可跳转
|
||||
</label>
|
||||
</div>
|
||||
<label class="space-y-1 block max-w-md">
|
||||
<span class="text-xs text-art-muted">卡片标签</span>
|
||||
<input v-model="model.latestWorkCard.label" class="admin-input w-full" />
|
||||
</label>
|
||||
<p class="text-xs text-art-muted">作品标题与链接取自作品列表最新一条</p>
|
||||
</section>
|
||||
|
||||
<!-- Bento section -->
|
||||
<section class="space-y-4 pt-4 border-t border-white/5">
|
||||
<h3 class="text-sm font-medium text-art-accent uppercase tracking-wider">Bento 区标题</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">区块标题</span>
|
||||
<input v-model="model.bento.sectionTitle" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">右侧提示</span>
|
||||
<input v-model="model.bento.sectionHint" class="admin-input w-full" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Featured post -->
|
||||
<section class="space-y-4 pt-4 border-t border-white/5">
|
||||
<h3 class="text-sm font-medium text-art-accent uppercase tracking-wider">精选文章卡</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.bento.featuredPost.visible" type="checkbox" class="accent-art-accent" />
|
||||
展示
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.bento.featuredPost.clickable" type="checkbox" class="accent-art-accent" />
|
||||
可跳转
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.bento.featuredPost.mode" type="radio" value="latest" class="accent-art-accent" />
|
||||
最新文章
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.bento.featuredPost.mode" type="radio" value="fixed" class="accent-art-accent" />
|
||||
指定文章
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="model.bento.featuredPost.mode === 'fixed'" class="space-y-3 p-4 rounded-lg border border-white/10 bg-white/[0.02]">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="postKeyword"
|
||||
class="admin-input flex-1"
|
||||
placeholder="搜索文章标题…"
|
||||
@keyup.enter="searchPosts"
|
||||
/>
|
||||
<button type="button" class="admin-btn-secondary text-sm shrink-0" :disabled="searchingPosts" @click="searchPosts">
|
||||
{{ searchingPosts ? '搜索中…' : '搜索' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="selectedPostTitle" class="text-sm text-art-accent">
|
||||
已选:#{{ model.bento.featuredPost.postId }} {{ selectedPostTitle }}
|
||||
<button type="button" class="ml-2 text-xs text-art-muted hover:text-white" @click="clearSelectedPost">清除</button>
|
||||
</p>
|
||||
<ul v-if="postOptions.length" class="max-h-48 overflow-y-auto space-y-1 custom-scrollbar">
|
||||
<li v-for="p in postOptions" :key="p.id">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm transition-colors"
|
||||
:class="model.bento.featuredPost.postId === p.id
|
||||
? 'bg-art-accent/20 text-art-accent border border-art-accent/40'
|
||||
: 'text-white/80 hover:bg-white/5 border border-transparent'"
|
||||
@click="selectPost(p)"
|
||||
>
|
||||
<span class="font-mono text-xs text-art-muted mr-2">#{{ p.id }}</span>
|
||||
{{ p.title }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ImagePicker
|
||||
v-model="model.bento.featuredPost.backgroundImage"
|
||||
label="背景图片"
|
||||
hint="留空或清除后前台使用默认 /bg/img.png"
|
||||
size="md"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Stats -->
|
||||
<section class="space-y-4 pt-4 border-t border-white/5">
|
||||
<h3 class="text-sm font-medium text-art-accent uppercase tracking-wider">统计卡</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.bento.stats.visible" type="checkbox" class="accent-art-accent" />
|
||||
展示
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.bento.stats.clickable" type="checkbox" class="accent-art-accent" />
|
||||
可跳转
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">数值</span>
|
||||
<input v-model="model.bento.stats.value" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">跳转链接</span>
|
||||
<input v-model="model.bento.stats.link" class="admin-input w-full" placeholder="可选" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">主标签</span>
|
||||
<input v-model="model.bento.stats.label" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">副标签</span>
|
||||
<input v-model="model.bento.stats.sublabel" class="admin-input w-full" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Snippets -->
|
||||
<section class="space-y-4 pt-4 border-t border-white/5">
|
||||
<h3 class="text-sm font-medium text-art-accent uppercase tracking-wider">代码实验室卡</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.bento.snippets.visible" type="checkbox" class="accent-art-accent" />
|
||||
展示
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.bento.snippets.clickable" type="checkbox" class="accent-art-accent" />
|
||||
可跳转
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="space-y-1 md:col-span-2">
|
||||
<span class="text-xs text-art-muted">注释行</span>
|
||||
<input v-model="model.bento.snippets.comment" class="admin-input w-full font-mono text-sm" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">关键字</span>
|
||||
<input v-model="model.bento.snippets.codeLines.keyword" class="admin-input w-full font-mono text-sm" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">函数名</span>
|
||||
<input v-model="model.bento.snippets.codeLines.func" class="admin-input w-full font-mono text-sm" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">返回字符串</span>
|
||||
<input v-model="model.bento.snippets.codeLines.string" class="admin-input w-full font-mono text-sm" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">CTA 文案</span>
|
||||
<input v-model="model.bento.snippets.ctaLabel" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1 md:col-span-2">
|
||||
<span class="text-xs text-art-muted">跳转链接</span>
|
||||
<input v-model="model.bento.snippets.link" class="admin-input w-full" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Services -->
|
||||
<section class="space-y-4 pt-4 border-t border-white/5">
|
||||
<h3 class="text-sm font-medium text-art-accent uppercase tracking-wider">服务 CTA 卡</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.bento.services.visible" type="checkbox" class="accent-art-accent" />
|
||||
展示
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="model.bento.services.clickable" type="checkbox" class="accent-art-accent" />
|
||||
可跳转
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">标题</span>
|
||||
<input v-model="model.bento.services.title" class="admin-input w-full" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">描述</span>
|
||||
<textarea v-model="model.bento.services.description" rows="3" class="admin-input w-full resize-none" />
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs text-art-muted">跳转链接</span>
|
||||
<input v-model="model.bento.services.link" class="admin-input w-full" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import ImagePicker from './ImagePicker.vue'
|
||||
import { fetchPost, getAdminPosts, type Post } from '../../services/api'
|
||||
import type { HomepageConfig } from '../../config/homepageConfig'
|
||||
import { parseHomepageConfig } from '../../config/homepageConfig'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const model = ref<HomepageConfig>(parseHomepageConfig(props.modelValue))
|
||||
const postKeyword = ref('')
|
||||
const searchingPosts = ref(false)
|
||||
const postOptions = ref<Post[]>([])
|
||||
const selectedPostTitle = ref('')
|
||||
|
||||
let syncingFromParent = false
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
syncingFromParent = true
|
||||
model.value = parseHomepageConfig(val)
|
||||
syncingFromParent = false
|
||||
if (model.value.bento.featuredPost.postId) {
|
||||
loadSelectedPostTitle(model.value.bento.featuredPost.postId)
|
||||
} else {
|
||||
selectedPostTitle.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
model,
|
||||
() => {
|
||||
if (syncingFromParent) return
|
||||
emit('update:modelValue', JSON.stringify(model.value))
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const selectPost = (p: Post) => {
|
||||
model.value.bento.featuredPost.postId = Number(p.id)
|
||||
selectedPostTitle.value = p.title
|
||||
}
|
||||
|
||||
const clearSelectedPost = () => {
|
||||
model.value.bento.featuredPost.postId = null
|
||||
selectedPostTitle.value = ''
|
||||
}
|
||||
|
||||
const searchPosts = async () => {
|
||||
searchingPosts.value = true
|
||||
try {
|
||||
const res = await getAdminPosts(1, 20, { keyword: postKeyword.value.trim() || undefined, isPublished: 1 })
|
||||
postOptions.value = res.list || []
|
||||
} catch {
|
||||
postOptions.value = []
|
||||
} finally {
|
||||
searchingPosts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadSelectedPostTitle = async (id: number) => {
|
||||
try {
|
||||
const post = await fetchPost(id)
|
||||
selectedPostTitle.value = post?.title && post.id ? post.title : `文章 #${id}`
|
||||
} catch {
|
||||
selectedPostTitle.value = `文章 #${id}`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (model.value.bento.featuredPost.mode === 'fixed') {
|
||||
searchPosts()
|
||||
if (model.value.bento.featuredPost.postId) {
|
||||
loadSelectedPostTitle(model.value.bento.featuredPost.postId)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => model.value.bento.featuredPost.mode,
|
||||
(mode) => {
|
||||
if (mode === 'fixed' && postOptions.value.length === 0) {
|
||||
searchPosts()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
187
client/src/config/homepageConfig.ts
Normal file
187
client/src/config/homepageConfig.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
export type FeaturedPostMode = 'latest' | 'fixed'
|
||||
|
||||
export interface HomepageConfig {
|
||||
hero: {
|
||||
eyebrow: string
|
||||
titleLines: [string, string, string]
|
||||
bioBefore: string
|
||||
bioName: string
|
||||
bioMid: string
|
||||
bioEmphasize: string
|
||||
ctaLabel: string
|
||||
ctaLink: string
|
||||
ctaClickable: boolean
|
||||
}
|
||||
latestWorkCard: {
|
||||
visible: boolean
|
||||
clickable: boolean
|
||||
label: string
|
||||
}
|
||||
bento: {
|
||||
sectionTitle: string
|
||||
sectionHint: string
|
||||
featuredPost: {
|
||||
visible: boolean
|
||||
clickable: boolean
|
||||
mode: FeaturedPostMode
|
||||
postId: number | null
|
||||
backgroundImage: string
|
||||
}
|
||||
stats: {
|
||||
visible: boolean
|
||||
clickable: boolean
|
||||
link: string
|
||||
value: string
|
||||
label: string
|
||||
sublabel: string
|
||||
}
|
||||
snippets: {
|
||||
visible: boolean
|
||||
clickable: boolean
|
||||
link: string
|
||||
comment: string
|
||||
codeLines: {
|
||||
keyword: string
|
||||
func: string
|
||||
string: string
|
||||
}
|
||||
ctaLabel: string
|
||||
}
|
||||
services: {
|
||||
visible: boolean
|
||||
clickable: boolean
|
||||
link: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_HOMEPAGE_CONFIG: HomepageConfig = {
|
||||
hero: {
|
||||
eyebrow: '前端架构师 & 创意开发者',
|
||||
titleLines: ['设计', '铸造数字', '灵魂'],
|
||||
bioBefore: '在代码的逻辑与设计的感性之间寻找平衡。我是',
|
||||
bioName: '年糕崽崽',
|
||||
bioMid: ',不仅仅构建页面,更在构建',
|
||||
bioEmphasize: '沉浸式体验',
|
||||
ctaLabel: '浏览作品集',
|
||||
ctaLink: '/works',
|
||||
ctaClickable: true,
|
||||
},
|
||||
latestWorkCard: {
|
||||
visible: true,
|
||||
clickable: true,
|
||||
label: '最新作品',
|
||||
},
|
||||
bento: {
|
||||
sectionTitle: '精选内容',
|
||||
sectionHint: '下滑探索更多',
|
||||
featuredPost: {
|
||||
visible: true,
|
||||
clickable: true,
|
||||
mode: 'latest',
|
||||
postId: null,
|
||||
backgroundImage: '/bg/img.png',
|
||||
},
|
||||
stats: {
|
||||
visible: true,
|
||||
clickable: false,
|
||||
link: '',
|
||||
value: '120+',
|
||||
label: '开源提交 (Commits)',
|
||||
sublabel: 'Vue, React, Three.js',
|
||||
},
|
||||
snippets: {
|
||||
visible: true,
|
||||
clickable: true,
|
||||
link: '/snippets',
|
||||
comment: '// React Hook: useArt',
|
||||
codeLines: {
|
||||
keyword: 'const',
|
||||
func: 'useCreative',
|
||||
string: 'Innovation',
|
||||
},
|
||||
ctaLabel: '访问代码实验室',
|
||||
},
|
||||
services: {
|
||||
visible: true,
|
||||
clickable: true,
|
||||
link: '/services',
|
||||
title: '需要独特的前端架构?',
|
||||
description: '不论是 WebGL 3D 交互网站,还是高性能的 SaaS 管理系统,我都能提供专业的解决方案。',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const DEFAULT_HOMEPAGE_CONFIG_JSON = JSON.stringify(DEFAULT_HOMEPAGE_CONFIG)
|
||||
|
||||
function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||
}
|
||||
|
||||
/** Deep-merge parsed config onto defaults so partial/corrupt JSON still works. */
|
||||
export function parseHomepageConfig(raw?: string | null): HomepageConfig {
|
||||
const base = structuredClone(DEFAULT_HOMEPAGE_CONFIG)
|
||||
if (!raw) return base
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!isObject(parsed)) return base
|
||||
|
||||
if (isObject(parsed.hero)) {
|
||||
const h = parsed.hero
|
||||
Object.assign(base.hero, {
|
||||
eyebrow: typeof h.eyebrow === 'string' ? h.eyebrow : base.hero.eyebrow,
|
||||
bioBefore: typeof h.bioBefore === 'string' ? h.bioBefore : base.hero.bioBefore,
|
||||
bioName: typeof h.bioName === 'string' ? h.bioName : base.hero.bioName,
|
||||
bioMid: typeof h.bioMid === 'string' ? h.bioMid : base.hero.bioMid,
|
||||
bioEmphasize: typeof h.bioEmphasize === 'string' ? h.bioEmphasize : base.hero.bioEmphasize,
|
||||
ctaLabel: typeof h.ctaLabel === 'string' ? h.ctaLabel : base.hero.ctaLabel,
|
||||
ctaLink: typeof h.ctaLink === 'string' ? h.ctaLink : base.hero.ctaLink,
|
||||
ctaClickable: typeof h.ctaClickable === 'boolean' ? h.ctaClickable : base.hero.ctaClickable,
|
||||
})
|
||||
if (Array.isArray(h.titleLines) && h.titleLines.length >= 3) {
|
||||
base.hero.titleLines = [
|
||||
String(h.titleLines[0] ?? base.hero.titleLines[0]),
|
||||
String(h.titleLines[1] ?? base.hero.titleLines[1]),
|
||||
String(h.titleLines[2] ?? base.hero.titleLines[2]),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(parsed.latestWorkCard)) {
|
||||
Object.assign(base.latestWorkCard, parsed.latestWorkCard)
|
||||
}
|
||||
|
||||
if (isObject(parsed.bento)) {
|
||||
const b = parsed.bento
|
||||
if (typeof b.sectionTitle === 'string') base.bento.sectionTitle = b.sectionTitle
|
||||
if (typeof b.sectionHint === 'string') base.bento.sectionHint = b.sectionHint
|
||||
|
||||
if (isObject(b.featuredPost)) {
|
||||
const fp = b.featuredPost
|
||||
Object.assign(base.bento.featuredPost, {
|
||||
visible: typeof fp.visible === 'boolean' ? fp.visible : base.bento.featuredPost.visible,
|
||||
clickable: typeof fp.clickable === 'boolean' ? fp.clickable : base.bento.featuredPost.clickable,
|
||||
mode: fp.mode === 'fixed' ? 'fixed' : 'latest',
|
||||
postId: typeof fp.postId === 'number' ? fp.postId : fp.postId === null ? null : base.bento.featuredPost.postId,
|
||||
backgroundImage: typeof fp.backgroundImage === 'string' ? fp.backgroundImage : base.bento.featuredPost.backgroundImage,
|
||||
})
|
||||
}
|
||||
if (isObject(b.stats)) Object.assign(base.bento.stats, b.stats)
|
||||
if (isObject(b.snippets)) {
|
||||
Object.assign(base.bento.snippets, b.snippets)
|
||||
if (isObject((b.snippets as Record<string, unknown>).codeLines)) {
|
||||
Object.assign(base.bento.snippets.codeLines, (b.snippets as Record<string, unknown>).codeLines)
|
||||
}
|
||||
// backward compat with plan name codeHtmlOrLines
|
||||
const legacy = (b.snippets as Record<string, unknown>).codeHtmlOrLines
|
||||
if (isObject(legacy)) Object.assign(base.bento.snippets.codeLines, legacy)
|
||||
}
|
||||
if (isObject(b.services)) Object.assign(base.bento.services, b.services)
|
||||
}
|
||||
} catch {
|
||||
return structuredClone(DEFAULT_HOMEPAGE_CONFIG)
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
export type SettingFieldType = 'text' | 'textarea' | 'number' | 'menu-checkboxes' | 'email'
|
||||
import { DEFAULT_HOMEPAGE_CONFIG_JSON } from './homepageConfig'
|
||||
|
||||
export type SettingFieldType = 'text' | 'textarea' | 'number' | 'menu-checkboxes' | 'email' | 'homepage-config'
|
||||
|
||||
export interface SettingSchemaItem {
|
||||
key: string
|
||||
label: string
|
||||
type: SettingFieldType
|
||||
group: 'site' | 'seo' | 'navigation' | 'pagination' | 'contact'
|
||||
group: 'site' | 'seo' | 'navigation' | 'pagination' | 'contact' | 'homepage'
|
||||
public: boolean
|
||||
default: string
|
||||
description: string
|
||||
@@ -14,6 +16,7 @@ export const SETTING_GROUPS: { id: SettingSchemaItem['group']; label: string }[]
|
||||
{ id: 'site', label: '站点信息' },
|
||||
{ id: 'seo', label: 'SEO 优化' },
|
||||
{ id: 'navigation', label: '导航菜单' },
|
||||
{ id: 'homepage', label: '首页内容' },
|
||||
{ id: 'pagination', label: '分页设置' },
|
||||
{ id: 'contact', label: '联系方式' },
|
||||
]
|
||||
@@ -64,6 +67,15 @@ export const SETTINGS_SCHEMA: SettingSchemaItem[] = [
|
||||
default: '["home","blog","columns","works","videos","snippets","about","services"]',
|
||||
description: '控制前台导航栏显示的菜单项',
|
||||
},
|
||||
{
|
||||
key: 'homepage_config',
|
||||
label: '首页内容',
|
||||
type: 'homepage-config',
|
||||
group: 'homepage',
|
||||
public: true,
|
||||
default: DEFAULT_HOMEPAGE_CONFIG_JSON,
|
||||
description: '配置首页 Hero 文案、Bento 卡片显隐/跳转/背景与精选文章来源',
|
||||
},
|
||||
{
|
||||
key: 'posts_per_page',
|
||||
label: '文章每页数量',
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
<!-- Hero -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-24 items-center animate-reveal">
|
||||
<div class="lg:col-span-7 space-y-8">
|
||||
<p class="text-art-accent font-mono text-sm tracking-widest uppercase mb-4">前端架构师 & 创意开发者</p>
|
||||
<p class="text-art-accent font-mono text-sm tracking-widest uppercase mb-4">{{ config.hero.eyebrow }}</p>
|
||||
<h1 class="font-serif text-5xl md:text-7xl lg:text-8xl leading-[1.1] text-art-text text-glow">
|
||||
<span class="block italic opacity-80">设计</span>
|
||||
<span class="block font-bold">铸造数字</span>
|
||||
<span class="block font-bold pl-12 md:pl-24">灵魂</span>
|
||||
<span class="block italic opacity-80">{{ config.hero.titleLines[0] }}</span>
|
||||
<span class="block font-bold">{{ config.hero.titleLines[1] }}</span>
|
||||
<span class="block font-bold pl-12 md:pl-24">{{ config.hero.titleLines[2] }}</span>
|
||||
</h1>
|
||||
<p class="text-art-muted text-lg md:text-xl max-w-xl leading-relaxed mt-8 font-light">
|
||||
在代码的逻辑与设计的感性之间寻找平衡。我是<span class="text-art-text font-bold">年糕崽崽</span>,不仅仅构建页面,更在构建<span class="text-art-text border-b border-art-border pb-0.5">沉浸式体验</span>。
|
||||
{{ config.hero.bioBefore }}<span class="text-art-text font-bold">{{ config.hero.bioName }}</span>{{ config.hero.bioMid }}<span class="text-art-text border-b border-art-border pb-0.5">{{ config.hero.bioEmphasize }}</span>。
|
||||
</p>
|
||||
<div class="pt-8 flex items-center gap-6">
|
||||
<button @click="$router.push('/works')" class="group flex items-center gap-2 text-art-text border-b border-art-text pb-1 hover:text-art-accent hover:border-art-accent transition-all">
|
||||
<span>浏览作品集</span>
|
||||
<div v-if="config.hero.ctaLabel" class="pt-8 flex items-center gap-6">
|
||||
<button
|
||||
type="button"
|
||||
class="group flex items-center gap-2 text-art-text border-b border-art-text pb-1 hover:text-art-accent hover:border-art-accent transition-all"
|
||||
:class="config.hero.ctaClickable ? 'cursor-pointer' : 'cursor-default'"
|
||||
@click="onHeroCta"
|
||||
>
|
||||
<span>{{ config.hero.ctaLabel }}</span>
|
||||
<Icon name="arrow-right" :size="16" class="transform group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -23,15 +28,28 @@
|
||||
<div class="relative w-full h-full">
|
||||
<div class="absolute inset-0 border border-art-border rounded-full rotate-12 scale-90"></div>
|
||||
<div class="absolute inset-0 border border-art-border rounded-full -rotate-6 scale-75"></div>
|
||||
<div
|
||||
id="home-hero-work"
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-80 bg-gradient-to-br from-art-text/5 to-transparent backdrop-blur-md border border-art-border rounded-sm shadow-2xl rotate-6 hover:rotate-0 transition-transform duration-700 z-10 flex flex-col p-6 justify-between cursor-pointer"
|
||||
@click="$router.push('/works/' + latestWork.id)"
|
||||
<div
|
||||
v-if="config.latestWorkCard.visible"
|
||||
id="home-hero-work"
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-80 bg-gradient-to-br from-art-text/5 to-transparent backdrop-blur-md border border-art-border rounded-sm shadow-2xl rotate-6 hover:rotate-0 transition-transform duration-700 z-10 flex flex-col p-6 justify-between"
|
||||
:class="config.latestWorkCard.clickable ? 'cursor-pointer' : 'cursor-default'"
|
||||
@click="onLatestWorkClick"
|
||||
>
|
||||
<div class="text-art-muted text-xs">最新作品</div>
|
||||
<div class="text-art-muted text-xs">{{ config.latestWorkCard.label }}</div>
|
||||
<div class="font-serif text-3xl italic text-art-text">{{ latestWork.title }}</div>
|
||||
<div class="flex justify-end"><Icon name="arrow-up-right" :size="24" class="text-art-text" /></div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-80 border border-dashed border-art-border rounded-sm bg-art-text/[0.03] backdrop-blur-md rotate-6 z-10 pointer-events-none flex flex-col items-center justify-center gap-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="absolute top-5 left-5 w-8 h-px bg-art-accent/40"></div>
|
||||
<div class="absolute top-5 left-5 w-px h-8 bg-art-accent/40"></div>
|
||||
<div class="absolute bottom-5 right-5 w-8 h-px bg-art-accent/30"></div>
|
||||
<div class="absolute bottom-5 right-5 w-px h-8 bg-art-accent/30"></div>
|
||||
<span class="font-mono text-[10px] tracking-[0.35em] uppercase text-art-muted/40">···</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,24 +57,30 @@
|
||||
<!-- Bento Grid -->
|
||||
<div class="space-y-6 animate-reveal" style="animation-delay: 0.2s;">
|
||||
<div class="flex items-end justify-between border-b border-art-border pb-4 mb-8">
|
||||
<h2 class="font-serif text-3xl italic text-art-text">精选内容</h2>
|
||||
<span class="font-mono text-xs text-art-muted">下滑探索更多</span>
|
||||
<h2 class="font-serif text-3xl italic text-art-text">{{ config.bento.sectionTitle }}</h2>
|
||||
<span class="font-mono text-xs text-art-muted">{{ config.bento.sectionHint }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 auto-rows-[300px]" id="bento-grid">
|
||||
<!-- Bento items will be rendered here -->
|
||||
<div
|
||||
class="md:col-span-2 art-card rounded-2xl p-8 flex flex-col justify-end group cursor-pointer"
|
||||
@click="$router.push('/blog/' + featuredPost.id)"
|
||||
<!-- Featured post -->
|
||||
<div
|
||||
v-if="config.bento.featuredPost.visible"
|
||||
class="md:col-span-2 art-card rounded-2xl p-8 flex flex-col justify-end group"
|
||||
:class="config.bento.featuredPost.clickable ? 'cursor-pointer' : 'cursor-default'"
|
||||
@click="onFeaturedPostClick"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent z-10"></div>
|
||||
<div class="absolute inset-0 bg-[url('/public/bg/img.png')] bg-cover bg-center transition-transform duration-700 group-hover:scale-105 opacity-60 mix-blend-overlay"></div>
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105 opacity-60 mix-blend-overlay"
|
||||
:style="{ backgroundImage: `url(${featuredBg})` }"
|
||||
></div>
|
||||
<div class="relative z-20 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="px-2 py-1 text-[10px] font-mono border border-white/20 rounded-full text-white backdrop-blur-sm">{{ featuredPost.categoryName || '未分类' }}</span>
|
||||
<span class="text-xs text-white/60">{{ featuredPost.date }}</span>
|
||||
</div>
|
||||
<h3 class="font-serif text-3xl text-white group-hover:text-art-accent transition-colors">
|
||||
{{ featuredPost.title }}</h3>
|
||||
{{ featuredPost.title }}
|
||||
</h3>
|
||||
<AuthorInfo
|
||||
class="mt-3"
|
||||
:name="featuredPost.userName"
|
||||
@@ -69,60 +93,89 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
<HomeBentoPlaceholder v-else :span="2" />
|
||||
|
||||
<!-- Stats -->
|
||||
<div
|
||||
v-if="config.bento.stats.visible"
|
||||
class="art-card rounded-2xl p-8 flex flex-col justify-between group"
|
||||
:class="config.bento.stats.clickable && config.bento.stats.link ? 'cursor-pointer' : ''"
|
||||
@click="onStatsClick"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<Icon name="code-2" :size="32" class="text-art-muted group-hover:text-art-accent transition-colors" />
|
||||
<Icon name="arrow-up-right" :size="20" class="text-art-muted/60" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-4xl font-mono font-bold text-art-text mb-2">120+</div>
|
||||
<div class="text-sm text-art-muted">开源提交 (Commits)</div>
|
||||
<div class="text-xs text-art-muted mt-2 opacity-60">Vue, React, Three.js</div>
|
||||
<div class="text-4xl font-mono font-bold text-art-text mb-2">{{ config.bento.stats.value }}</div>
|
||||
<div class="text-sm text-art-muted">{{ config.bento.stats.label }}</div>
|
||||
<div class="text-xs text-art-muted mt-2 opacity-60">{{ config.bento.stats.sublabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="art-card rounded-2xl p-6 md:p-8 flex flex-col justify-between group cursor-pointer bg-art-bg"
|
||||
@click="$router.push('/snippets')"
|
||||
<HomeBentoPlaceholder v-else :span="1" />
|
||||
|
||||
<!-- Snippets -->
|
||||
<div
|
||||
v-if="config.bento.snippets.visible"
|
||||
class="art-card rounded-2xl p-6 md:p-8 flex flex-col justify-between group bg-art-bg"
|
||||
:class="config.bento.snippets.clickable ? 'cursor-pointer' : 'cursor-default'"
|
||||
@click="onSnippetsClick"
|
||||
>
|
||||
<div class="font-mono text-xs text-art-muted mb-4">// React Hook: useArt</div>
|
||||
<div class="font-mono text-xs text-art-muted mb-4">{{ config.bento.snippets.comment }}</div>
|
||||
<div class="font-mono text-sm text-art-muted overflow-hidden opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
<span class="code-keyword">const</span> <span class="code-func">useCreative</span> = () => {<br>
|
||||
<span class="code-keyword">return</span> <span class="code-string">"Innovation"</span>;<br>
|
||||
<span class="code-keyword">{{ config.bento.snippets.codeLines.keyword }}</span>
|
||||
<span> </span>
|
||||
<span class="code-func">{{ config.bento.snippets.codeLines.func }}</span>
|
||||
<span> = () => {'{'}</span><br>
|
||||
<span class="code-keyword">return</span>
|
||||
<span> </span>
|
||||
<span class="code-string">"{{ config.bento.snippets.codeLines.string }}"</span>;<br>
|
||||
}
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2 text-sm font-medium text-art-text">
|
||||
<span>访问代码实验室</span>
|
||||
<span>{{ config.bento.snippets.ctaLabel }}</span>
|
||||
<Icon name="chevron-right" :size="16" class="text-art-accent" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="md:col-span-2 art-card rounded-2xl p-8 md:p-12 flex flex-col md:flex-row items-center justify-between gap-8 group cursor-pointer bg-gradient-to-r from-art-surface to-transparent"
|
||||
@click="$router.push('/services')"
|
||||
<HomeBentoPlaceholder v-else :span="1" />
|
||||
|
||||
<!-- Services -->
|
||||
<div
|
||||
v-if="config.bento.services.visible"
|
||||
class="md:col-span-2 art-card rounded-2xl p-8 md:p-12 flex flex-col md:flex-row items-center justify-between gap-8 group bg-gradient-to-r from-art-surface to-transparent"
|
||||
:class="config.bento.services.clickable ? 'cursor-pointer' : 'cursor-default'"
|
||||
@click="onServicesClick"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-serif text-3xl text-art-text">需要独特的前端架构?</h3>
|
||||
<p class="text-art-muted max-w-sm">不论是 WebGL 3D 交互网站,还是高性能的 SaaS 管理系统,我都能提供专业的解决方案。</p>
|
||||
<h3 class="font-serif text-3xl text-art-text">{{ config.bento.services.title }}</h3>
|
||||
<p class="text-art-muted max-w-sm">{{ config.bento.services.description }}</p>
|
||||
</div>
|
||||
<div class="h-16 w-16 rounded-full border border-art-border flex items-center justify-center group-hover:bg-art-text group-hover:text-black transition-all duration-300">
|
||||
<Icon name="arrow-right" :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<HomeBentoPlaceholder v-else :span="2" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchWorks, fetchPosts, Work, Post } from '../services/api'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchWorks, fetchPosts, fetchPost, type Work, type Post } from '../services/api'
|
||||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||||
import { loadPublicSettings } from '../composables/usePublicSettings'
|
||||
import { parseHomepageConfig, type HomepageConfig } from '../config/homepageConfig'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import AuthorInfo from '../components/AuthorInfo.vue'
|
||||
import HomeBentoPlaceholder from '../components/HomeBentoPlaceholder.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { initObserver } = useScrollAnimation()
|
||||
|
||||
const config = ref<HomepageConfig>(parseHomepageConfig())
|
||||
|
||||
const latestWork = ref<Work>({
|
||||
id: 'nova',
|
||||
title: 'Nova 交易平台',
|
||||
@@ -133,7 +186,7 @@ const latestWork = ref<Work>({
|
||||
techStack: [],
|
||||
gallery: [],
|
||||
links: {},
|
||||
next: ''
|
||||
next: '',
|
||||
})
|
||||
|
||||
const featuredPost = ref<Post>({
|
||||
@@ -142,36 +195,83 @@ const featuredPost = ref<Post>({
|
||||
categoryId: 0,
|
||||
date: '2025-10-24',
|
||||
excerpt: '',
|
||||
content: ''
|
||||
content: '',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const featuredBg = computed(() => {
|
||||
const url = config.value.bento.featuredPost.backgroundImage?.trim()
|
||||
return url || '/bg/img.png'
|
||||
})
|
||||
|
||||
const navigate = (link: string) => {
|
||||
if (!link) return
|
||||
if (/^https?:\/\//i.test(link)) {
|
||||
window.open(link, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
router.push(link)
|
||||
}
|
||||
|
||||
const onHeroCta = () => {
|
||||
if (!config.value.hero.ctaClickable) return
|
||||
navigate(config.value.hero.ctaLink || '/works')
|
||||
}
|
||||
|
||||
const onLatestWorkClick = () => {
|
||||
if (!config.value.latestWorkCard.clickable) return
|
||||
router.push(`/works/${latestWork.value.id}`)
|
||||
}
|
||||
|
||||
const onFeaturedPostClick = () => {
|
||||
if (!config.value.bento.featuredPost.clickable) return
|
||||
if (!featuredPost.value.id) return
|
||||
router.push(`/blog/${featuredPost.value.id}`)
|
||||
}
|
||||
|
||||
const onStatsClick = () => {
|
||||
if (!config.value.bento.stats.clickable || !config.value.bento.stats.link) return
|
||||
navigate(config.value.bento.stats.link)
|
||||
}
|
||||
|
||||
const onSnippetsClick = () => {
|
||||
if (!config.value.bento.snippets.clickable) return
|
||||
navigate(config.value.bento.snippets.link || '/snippets')
|
||||
}
|
||||
|
||||
const onServicesClick = () => {
|
||||
if (!config.value.bento.services.clickable) return
|
||||
navigate(config.value.bento.services.link || '/services')
|
||||
}
|
||||
|
||||
const fetchHomeData = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [works, posts] = await Promise.all([
|
||||
fetchWorks(),
|
||||
fetchPosts()
|
||||
])
|
||||
|
||||
const settings = await loadPublicSettings()
|
||||
config.value = parseHomepageConfig(settings.homepage_config)
|
||||
|
||||
const worksPromise = fetchWorks()
|
||||
let postPromise: Promise<void>
|
||||
|
||||
const fp = config.value.bento.featuredPost
|
||||
if (fp.visible && fp.mode === 'fixed' && fp.postId) {
|
||||
postPromise = fetchPost(fp.postId).then((post) => {
|
||||
if (post && post.id) featuredPost.value = post
|
||||
})
|
||||
} else {
|
||||
postPromise = fetchPosts(undefined, undefined, undefined, undefined, 1, 1).then((posts) => {
|
||||
const postsList = 'list' in posts ? posts.list : []
|
||||
if (postsList && postsList.length > 0) {
|
||||
featuredPost.value = postsList[0]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const [works] = await Promise.all([worksPromise, postPromise])
|
||||
if (works && works.length > 0) {
|
||||
latestWork.value = works[0]
|
||||
}
|
||||
|
||||
// fetchPosts 现在返回 PaginationResponse<Post>,需要取 .list
|
||||
const postsList = 'list' in posts ? posts.list : []
|
||||
if (postsList && postsList.length > 0) {
|
||||
featuredPost.value = postsList[0]
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching home data:', err)
|
||||
error.value = '获取首页数据失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
// 启用滚动动画
|
||||
initObserver()
|
||||
}
|
||||
}
|
||||
@@ -179,4 +279,4 @@ const fetchHomeData = async () => {
|
||||
onMounted(() => {
|
||||
fetchHomeData()
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -53,6 +53,12 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<HomepageConfigForm
|
||||
v-else-if="schema.type === 'homepage-config'"
|
||||
v-model="formValues[schema.key]"
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
v-else-if="schema.type === 'textarea'"
|
||||
v-model="formValues[schema.key]"
|
||||
@@ -141,6 +147,7 @@ import {
|
||||
import { useToast } from '../../composables/useToast'
|
||||
import { usePublicSettings } from '../../composables/usePublicSettings'
|
||||
import EmailAutocomplete from '../../components/ui/EmailAutocomplete.vue'
|
||||
import HomepageConfigForm from '../../components/admin/HomepageConfigForm.vue'
|
||||
import {
|
||||
SETTING_GROUPS,
|
||||
SETTINGS_SCHEMA,
|
||||
@@ -196,6 +203,7 @@ const initFormValues = () => {
|
||||
const buildPayload = (): Record<string, string> => {
|
||||
const payload: Record<string, string> = { ...formValues }
|
||||
payload.visible_menus = JSON.stringify(selectedMenus.value)
|
||||
// homepage_config is already maintained as JSON string by HomepageConfigForm
|
||||
return payload
|
||||
}
|
||||
|
||||
|
||||
@@ -1417,6 +1417,7 @@ export interface PublicSettings {
|
||||
site_author?: string
|
||||
site_keywords?: string
|
||||
visible_menus?: string
|
||||
homepage_config?: string
|
||||
posts_per_page?: string
|
||||
works_per_page?: string
|
||||
snippets_per_page?: string
|
||||
|
||||
Reference in New Issue
Block a user