增加更多可配置的部分

This commit is contained in:
李琦
2026-07-14 13:29:22 +08:00
parent b88a72e613
commit f38a80ca71
8 changed files with 751 additions and 63 deletions

View 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>

View 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>

View 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
}

View File

@@ -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: '文章每页数量',

View File

@@ -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>
&nbsp;&nbsp;<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> = () =&gt; {'{'}</span><br>
&nbsp;&nbsp;<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>

View File

@@ -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
}

View File

@@ -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

View File

@@ -23,7 +23,7 @@ func GetSettings(c *gin.Context) {
publicKeys := []string{
"site_title", "site_description", "site_author", "site_keywords",
"visible_menus", "posts_per_page", "works_per_page", "snippets_per_page",
"footer_icp", "contact_email",
"footer_icp", "contact_email", "homepage_config",
}
for _, key := range publicKeys {