个人博客系统
个人博客系统
一个基于 Spring Boot + Vue.js 的现代化个人博客系统。
功能特点
- 📝 Markdown 编辑器支持
- 🏷️ 文章标签管理
- 🔍 全文搜索功能
- 📱 响应式设计
- 🌓 深色模式支持
- 📊 文章分类统计
- 🖼️ 图片上传功能
- 💾 基于文件系统的存储
技术栈
后端
- Spring Boot 2.3.4
- Hutool 工具包
- 基于文件系统的存储方案
前端
- Vue 3
- Vue Router
- Tailwind CSS
- v-md-editor (Markdown 编辑器)
- Axios
快速开始
环境要求
- JDK 8+
- Node.js 14+
- Maven 3.6+
后端启动
cd 后端
mvn spring-boot:run
前端启动
cd 前端
npm install
npm run serve
项目结构
personal-blog-system/
├── 前端/ # Vue.js 前端项目
│ ├── src/ # 源代码
│ ├── public/ # 静态资源
│ └── package.json # 项目依赖
└── 后端/ # Spring Boot 后端项目
├── src/ # 源代码
└── pom.xml # Maven 配置
配置说明
后端配置
主要配置文件位于 后端/src/main/resources/application.properties
:
server.port
: 服务器端口base.dir
: 文章存储目录metadata.file
: 元数据文件路径upload.dir
: 图片上传目录
前端配置
主要配置文件位于 前端/vue.config.js
:
- 开发服务器配置
- 构建配置
- 代理配置
主要功能说明
文章管理
- 支持创建、编辑、删除文章
- Markdown 格式支持
- 自动生成文章摘要
- 标签管理
文章展示
- 响应式布局
- 代码高亮
- 标签筛选
- 时间线展示
搜索功能
- 支持按标题搜索
- 支持按标签筛选
- 支持按时间排序
演示截图
1.首页
2.文章书写页面
3.文章阅读页面
调整为个人博客网站操作说明
- 只需要将前台的文章书写页面屏蔽掉,然后将文章列表页面的修改按钮屏蔽掉
- 后台的相应文章修改和新增接口也屏蔽掉,即转换为个人博客网站
源码下载
个人博客系统
核心源码
Home.vue
<template>
<div style="min-height: 1024px">
<header class="fixed top-0 left-0 right-0 bg-white shadow-sm z-50">
<div class="max-w-7xl mx-auto px-4">
<div class="flex items-center justify-between h-16">
<div class="flex items-center">
<router-link to="/" class="text-2xl font-['Pacifico'] text-primary">logo</router-link>
<nav class="ml-10 space-x-8">
<a href="#" class="text-gray-900 hover:text-primary">首页</a>
<a href="#" @click.prevent="scrollToElem('categories')" class="text-gray-600 hover:text-primary">热门分类</a>
<a href="#" @click.prevent="scrollToElem('latest')" class="text-gray-600 hover:text-primary">最新文章</a>
<a href="#" @click.prevent="scrollToElem('recommended')" class="text-gray-600 hover:text-primary">推荐阅读</a>
<a href="#" @click.prevent="scrollToElem('about')" class="text-gray-600 hover:text-primary">关于我们</a>
</nav>
</div>
<div class="flex items-center">
<div class="relative">
<input
v-model="searchKeyword"
type="text"
placeholder="搜索文章..."
@keyup.enter="handleSearch"
class="w-64 h-10 pl-10 pr-4 rounded-full bg-gray-100 border-none focus:outline-none focus:ring-2 focus:ring-primary/20 text-sm"
>
<i class="fas fa-search absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-sm cursor-pointer" @click="handleSearch"></i>
</div>
<button @click="toCreatePost" class="ml-4 px-4 py-2 bg-primary text-white !rounded-button hover:bg-primary/90 whitespace-nowrap">
写文章
</button>
</div>
</div>
</div>
</header>
<main class="mt-16 min-h-screen">
<section class="hero-section relative h-[600px] flex items-center">
<div class="max-w-7xl mx-auto px-4 w-full">
<div class="max-w-2xl bg-white/90 backdrop-blur-sm p-12 rounded-lg">
<h1 class="text-4xl font-bold text-gray-900 mb-6">探索技术与创意的交汇</h1>
<p class="text-lg text-gray-700 mb-8">在这里,我们分享技术见解、设计灵感和创新思维。让我们一起探索数字世界的无限可能。</p>
<button class="px-8 py-3 bg-primary text-white !rounded-button hover:bg-primary/90 whitespace-nowrap text-lg" @click="toSearch">
开始阅读
</button>
</div>
</div>
</section>
<section id="categories" class="py-16 bg-white">
<div class="max-w-7xl mx-auto px-4">
<h2 class="text-2xl font-bold text-gray-900 mb-8">热门分类</h2>
<div class="grid grid-cols-4 gap-6">
<div class="category-card group" @click="handleCategoryClick('技术开发')">
<div class="aspect-w-4 aspect-h-3 rounded-lg overflow-hidden mb-4">
<img src="@/image/8b61d5e137821556fc4f8579832e31a9.jpg" alt="技术"
class="w-full h-full object-cover category-image">
</div>
<h3 class="font-medium text-gray-900">data.技术开发</h3>
<p class="text-sm text-gray-500">{{ data.技术开发 }} 篇文章</p>
</div>
<div class="category-card group" @click="handleCategoryClick('设计创意')">
<div class="aspect-w-4 aspect-h-3 rounded-lg overflow-hidden mb-4">
<img src="@/image/d347ff83edd65ed540ab221eaf6492f5.jpg" alt="设计"
class="w-full h-full object-cover category-image">
</div>
<h3 class="font-medium text-gray-900">设计创意</h3>
<p class="text-sm text-gray-500">{{ data.设计创意 }} 篇文章</p>
</div>
<div class="category-card group" @click="handleCategoryClick('产品思维')">
<div class="aspect-w-4 aspect-h-3 rounded-lg overflow-hidden mb-4">
<img src="@/image/c6c5099dde513aca3cd5b3bb0711cf82.jpg" alt="产品"
class="w-full h-full object-cover category-image">
</div>
<h3 class="font-medium text-gray-900">产品思维</h3>
<p class="text-sm text-gray-500">{{ data.产品思维 }} 篇文章</p>
</div>
<div class="category-card group" @click="handleCategoryClick('生活感悟')">
<div class="aspect-w-4 aspect-h-3 rounded-lg overflow-hidden mb-4">
<img src="@/image/1fb2c11596c1d0e87f38b4ae78434971.jpg" alt="生活"
class="w-full h-full object-cover category-image">
</div>
<h3 class="font-medium text-gray-900">生活感悟</h3>
<p class="text-sm text-gray-500">{{ data.生活感悟 }} 篇文章</p>
</div>
</div>
</div>
</section>
<section id="latest" class="py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4">
<div class="flex justify-between items-center mb-8">
<h2 class="text-2xl font-bold text-gray-900">最新文章</h2>
<div class="flex space-x-2">
</div>
</div>
<div class="grid grid-cols-2 gap-8">
<article class="bg-white rounded-lg shadow-sm overflow-hidden article-card" v-if="topPost1.id">
<img src="@/image/771d287c74c79cf46d39a0fe0cdaa1f1.jpg" alt="文章配图"
class="w-full h-64 object-cover">
<div class="p-6">
<div class="flex items-center space-x-4 mb-4">
<img src="@/image/17c3f9f4f41171ffe7c623beb4627fa8.jpg" alt="作者头像"
class="w-10 h-10 rounded-full">
<div>
<h4 class="font-medium text-gray-900">冰冰一号</h4>
<p class="text-sm text-gray-500">{{ topPost1.createTime }}</p>
</div>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-2">
<router-link :to="`/post/${topPost1.id}`" class="hover:text-primary">
{{ topPost1.title }}
</router-link>
</h3>
<p class="text-gray-600 mb-4 line-clamp-2 min-h-[48px]">{{ topPost1.summary }}</p>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-500"></span>
<span class="text-sm text-gray-500"></span>
</div>
<div class="flex items-center space-x-2">
<span class="px-3 py-1 text-sm text-primary bg-primary/10 rounded-full">{{ topPost1.tag }}</span>
</div>
</div>
</div>
</article>
<article class="bg-white rounded-lg shadow-sm overflow-hidden article-card" v-if="topPost2.id">
<img src="@/image/4adb9fb8469c6d5721f141cd78770e33.jpg" alt="文章配图"
class="w-full h-64 object-cover">
<div class="p-6">
<div class="flex items-center space-x-4 mb-4">
<img src="@/image/9d1e781d74a8e978da68f67dfad16a43.jpg" alt="作者头像"
class="w-10 h-10 rounded-full">
<div>
<h4 class="font-medium text-gray-900">冰冰一号</h4>
<p class="text-sm text-gray-500">{{ topPost2.createTime }}</p>
</div>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-2">
<router-link :to="`/post/${topPost2.id}`" class="hover:text-primary">
{{ topPost2.title }}
</router-link>
</h3>
<p class="text-gray-600 mb-4 line-clamp-2 min-h-[48px]">{{ topPost2.summary }}</p>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-500"></span>
<span class="text-sm text-gray-500"></span>
</div>
<div class="flex items-center space-x-2">
<span class="px-3 py-1 text-sm text-primary bg-primary/10 rounded-full">{{ topPost2.tag }}</span>
</div>
</div>
</div>
</article>
</div>
</div>
</section>
<section id="recommended" class="py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4">
<h2 class="text-2xl font-bold text-gray-900 mb-8">推荐阅读</h2>
<div class="grid grid-cols-3 gap-6">
<article class="bg-gray-50 rounded-lg p-6 article-card" v-if="recommendedPost1.id">
<div class="flex items-center space-x-4 mb-4">
<img src="@/image/fee96823961b14e8b6fcc9b91ba91ee0.jpg" alt="作者头像"
class="w-10 h-10 rounded-full">
<div>
<h4 class="font-medium text-gray-900">冰冰一号</h4>
<p class="text-sm text-gray-500">{{ recommendedPost1.createTime }}</p>
</div>
</div>
<h3 class="text-lg font-bold text-gray-900 mb-2">
<router-link :to="`/post/${recommendedPost1.id}`" class="hover:text-primary">
{{ recommendedPost1.title }}
</router-link>
</h3>
<p class="text-gray-600 mb-4 line-clamp-2 min-h-[48px]">{{ recommendedPost1.summary }}</p>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500"></span>
<span class="px-3 py-1 text-sm text-primary bg-primary/10 rounded-full">{{ recommendedPost1.tag }}</span>
</div>
</article>
<article class="bg-gray-50 rounded-lg p-6 article-card" v-if="recommendedPost2.id">
<div class="flex items-center space-x-4 mb-4">
<img src="@/image/7ce370d4dc07c7b902604c928cb381b6.jpg" alt="作者头像"
class="w-10 h-10 rounded-full">
<div>
<h4 class="font-medium text-gray-900">冰冰一号</h4>
<p class="text-sm text-gray-500">{{ recommendedPost2.createTime }}</p>
</div>
</div>
<h3 class="text-lg font-bold text-gray-900 mb-2">
<router-link :to="`/post/${recommendedPost2.id}`" class="hover:text-primary">
{{ recommendedPost2.title }}
</router-link>
</h3>
<p class="text-gray-600 mb-4 line-clamp-2 min-h-[48px]">{{ recommendedPost2.summary }}</p>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500"></span>
<span class="px-3 py-1 text-sm text-primary bg-primary/10 rounded-full">{{ recommendedPost2.tag }}</span>
</div>
</article>
<article class="bg-gray-50 rounded-lg p-6 article-card" v-if="recommendedPost3.id">
<div class="flex items-center space-x-4 mb-4">
<img src="@/image/ec22dc1fba08639f9a1909e30929c645.jpg" alt="作者头像"
class="w-10 h-10 rounded-full">
<div>
<h4 class="font-medium text-gray-900">冰冰一号</h4>
<p class="text-sm text-gray-500">{{ recommendedPost3.createTime }}</p>
</div>
</div>
<h3 class="text-lg font-bold text-gray-900 mb-2">
<router-link :to="`/post/${recommendedPost3.id}`" class="hover:text-primary">
{{ recommendedPost3.title }}
</router-link>
</h3>
<p class="text-gray-600 mb-4 line-clamp-2 min-h-[48px]">{{ recommendedPost3.summary }}</p>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500"></span>
<span class="px-3 py-1 text-sm text-primary bg-primary/10 rounded-full">{{ recommendedPost3.tag }}</span>
</div>
</article>
</div>
</div>
</section>
<section class="py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4">
<div class="bg-white rounded-lg p-12 text-center">
<h2 class="text-3xl font-bold text-gray-900 mb-4">订阅最新文章</h2>
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">及时获取最新的技术文章和行业动态,我们会定期发送精选内容到您的邮箱</p>
<div class="flex items-center justify-center space-x-4">
<input type="email" placeholder="请输入您的邮箱地址"
class="w-96 h-12 px-4 rounded-lg bg-gray-50 border-none focus:outline-none focus:ring-2 focus:ring-primary/20">
<button class="px-8 py-3 bg-primary text-white !rounded-button hover:bg-primary/90 whitespace-nowrap">
立即订阅
</button>
</div>
</div>
</div>
</section>
</main>
<footer id="about" class="bg-white border-t border-gray-200">
<div class="max-w-7xl mx-auto px-4 py-12">
<div class="grid grid-cols-4 gap-8 mb-8">
<div>
<h3 class="text-lg font-bold text-gray-900 mb-4">关于我们</h3>
<p class="text-gray-600">分享技术,连接思想,创造价值</p>
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 mb-4">友情链接</h3>
<ul class="space-y-2">
<li><a href="#" class="text-gray-600 hover:text-primary">掘金</a></li>
<li><a href="#" class="text-gray-600 hover:text-primary">InfoQ</a></li>
<li><a href="#" class="text-gray-600 hover:text-primary">开源中国</a></li>
</ul>
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 mb-4">联系我们</h3>
<ul class="space-y-2">
<li class="text-gray-600">邮箱:contact@example.com</li>
<li class="text-gray-600">微信:blog_official</li>
</ul>
</div>
<div>
<h3 class="text-lg font-bold text-gray-900 mb-4">关注我们</h3>
<div class="flex space-x-4">
<a href="#"
class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 hover:bg-primary hover:text-white">
<i class="fab fa-weixin"></i>
</a>
<a href="#"
class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 hover:bg-primary hover:text-white">
<i class="fab fa-weibo"></i>
</a>
<a href="#"
class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 hover:bg-primary hover:text-white">
<i class="fab fa-github"></i>
</a>
</div>
</div>
</div>
<div class="pt-8 border-t border-gray-200 text-center text-gray-600">
<p>© 2024 个人博客. All rights reserved.</p>
</div>
</div>
</footer>
<!-- 回到顶部按钮 -->
<div v-show="showBackToTop"
@click="scrollToTop"
class="fixed right-8 bottom-8 bg-white w-10 h-10 rounded-full shadow-lg flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-all duration-300"
title="回到顶部">
<i class="fas fa-arrow-up text-gray-600"></i>
</div>
</div>
</template>
<script setup>
import {ref, onMounted, onUnmounted, reactive} from 'vue'
import {useRouter} from "vue-router";
import {postApi} from "@/api/post";
// 控制回到顶部按钮的显示
const showBackToTop = ref(false)
// 监听滚动事件
const handleScroll = () => {
showBackToTop.value = window.scrollY > 300
}
// 回到顶部方法
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
})
}
onMounted(() => {
// 添加滚动监听
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
// 移除滚动监听
window.removeEventListener('scroll', handleScroll)
})
function scrollToElem(id) {
const elem = document.getElementById(id);
if (elem) {
elem.scrollIntoView({ behavior: 'smooth' });
}
}
const router = useRouter();
function toCreatePost() {
router.push({
path: "/createPost",
})
}
const searchKeyword = ref("");
// 添加搜索处理函数
const handleSearch = () => {
router.push({
path: '/search',
query: {
keyword: searchKeyword.value.trim()
}
});
};
const data = reactive({
技术开发: 0,
设计创意: 0,
产品思维: 0,
生活感悟: 0,
});
onMounted(async () => {
const countMap = await postApi.getCategoriesData();
// 更新各分类的文章数量
Object.keys(data).forEach(category => {
data[category] = countMap[category] || 0;
});
});
const handleCategoryClick = (category) => {
router.push({
path: '/search',
query: {
tag: category
}
});
};
const topPost1 = reactive({});
const topPost2 = reactive({});
function setProperty(target, origin) {
target.id = origin.id;
target.title = origin.title;
target.summary = origin.summary;
target.tag = origin.tags[0];
target.createTime = formatVisitTime(origin.createTime);
}
onMounted(async () => {
const topTwoPost = await postApi.getTopTwoPost();
setProperty(topPost1, topTwoPost.post1);
setProperty(topPost2, topTwoPost.post2);
});
// 格式化访问时间
const formatVisitTime = (timestamp) => {
const minutes = Math.floor((Date.now() - new Date(timestamp)) / 1000 / 60)
if (minutes < 60) {
return `${minutes} 分钟前`
} else if (minutes < 1440) {
return `${Math.floor(minutes / 60)} 小时前`
} else {
return `${Math.floor(minutes / 1440)} 天前`
}
}
const toSearch = () => {
router.push({
path: '/search',
});
};
const recommendedPost1 = reactive({});
const recommendedPost2 = reactive({});
const recommendedPost3 = reactive({});
onMounted(async () => {
const recommendedPosts = await postApi.getRecommendedTopThreePost();
if (recommendedPosts.length >= 1) {
setProperty(recommendedPost1, recommendedPosts[0]);
}
if (recommendedPosts.length >= 2) {
setProperty(recommendedPost2, recommendedPosts[1]);
}
if (recommendedPosts.length >= 3) {
setProperty(recommendedPost3, recommendedPosts[2]);
}
});
</script>
<style scoped>
.hero-section {
background-image: url('@/image/5419bc35abe0fb8251a2cdca72c487ce.jpg');
background-size: cover;
background-position: center;
}
.article-card:hover {
transform: translateY(-4px);
transition: all 0.3s ease;
}
.category-image {
transition: transform 0.3s ease;
}
.category-card:hover .category-image {
transform: scale(1.05);
}
</style>
PostDetail.vue
<template>
<Layout>
<main class="flex-1 container mx-auto px-4 py-8">
<div v-if="!postLoading" class="mx-auto bg-white rounded-lg shadow-lg">
<!-- 帖子内容 -->
<article class="p-6">
<h1 class="text-2xl font-bold mb-4">{{ post.title }}</h1>
<div class="flex justify-end items-center space-x-4 mb-6">
<div class="flex items-center space-x-2">
<span v-for="(tag, index) in post.tags"
:key="index"
class="px-3 py-1 bg-primary/5 text-primary rounded-full text-sm">
{{ tag }}
</span>
</div>
<span class="text-sm text-gray-500">{{ formatDate(post.createTime) }}</span>
</div>
<!-- 帖子正文 -->
<v-md-editor
v-model="post.content"
mode="preview"
></v-md-editor>
</article>
</div>
</main>
</Layout>
<!-- 回到顶部按钮 -->
<button
v-show="showBackToTop"
@click="scrollToTop"
class="fixed bottom-8 right-8 w-10 h-10 bg-primary text-white rounded-full shadow-lg hover:bg-primary/90 transition-all duration-300 flex items-center justify-center"
title="回到顶部">
<i class="fas fa-angle-up text-lg"></i>
</button>
</template>
<script setup>
import {ref, onMounted, onUnmounted} from 'vue'
import { useRoute } from 'vue-router'
import { postApi } from '@/api/post'
import Message from '@/utils/message'
import Layout from '@/components/Layout.vue'
const route = useRoute()
const post = ref({})
// 格式化日期
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
const postLoading = ref(true)
// 获取帖子详情和点赞状态
const fetchPostDetail = async () => {
try {
const res = await postApi.getDetail(route.params.id)
post.value = res.data
postLoading.value = false;
} catch (error) {
Message.error('获取帖子详情失败')
}
}
onMounted(async () => {
await fetchPostDetail()
})
// 控制回到顶部按钮显示
const showBackToTop = ref(false)
// 监听滚动事件
const handleScroll = () => {
showBackToTop.value = window.scrollY > 300
}
// 回到顶部
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
})
}
onMounted(() => {
window.addEventListener('scroll', handleScroll)
})
// 组件卸载时移除事件监听
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<style scoped>
</style>
CreatePost.vue
<template>
<Layout>
<main class="flex-1 flex items-center justify-center">
<div class="container mx-auto px-4 py-8" style="width: 100%; max-width: 100%">
<div class="mx-auto bg-white rounded-lg p-6 shadow-lg">
<h1 class="text-2xl font-bold mb-6">{{ isEdit ? '编辑帖子' : '发布新帖子' }}</h1>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- 标题输入 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">标题</label>
<input
v-model="postForm.title"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
placeholder="请输入帖子标题">
<p v-if="errors.title" class="mt-1 text-sm text-red-600">{{ errors.title }}</p>
</div>
<!-- 标签选择 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">标签</label>
<div class="flex flex-wrap gap-2">
<div
v-for="tag in availableTags"
:key="tag.id"
@click="toggleTag(tag)"
:class="[
'px-3 py-1 rounded-full text-sm cursor-pointer transition-colors',
postForm.tags.includes(tag.id)
? 'bg-primary text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
>
{{ tag.name }}
</div>
</div>
<p v-if="errors.tags" class="mt-1 text-sm text-red-600">{{ errors.tags }}</p>
</div>
<!-- Markdown编辑器 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">内容</label>
<v-md-editor
v-model="postForm.content"
height="400px"
:disabled-menus="[]"
@change="handleEditorChange"
@upload-image="handleUploadImage"
></v-md-editor>
<p v-if="errors.content" class="mt-1 text-sm text-red-600">{{ errors.content }}</p>
</div>
<!-- 提交按钮 -->
<div class="flex justify-end space-x-4">
<button type="button" @click="$router.back()" class="px-6 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
取消
</button>
<button type="submit" :disabled="loading" class="px-6 py-2 bg-primary text-white rounded-md hover:bg-primary/90 disabled:opacity-50">
<i v-if="loading" class="fas fa-circle-notch fa-spin mr-2"></i>
{{ loading ? (isEdit ? '保存中...' : '发布中...') : (isEdit ? '保存修改' : '发布帖子') }}
</button>
</div>
</form>
</div>
</div>
</main>
</Layout>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import {useRoute, useRouter} from 'vue-router';
import { postApi } from '@/api/post';
import {tagList} from '@/api/tag';
import Message from '@/utils/message';
import Layout from "@/components/Layout.vue";
import request, {baseURL} from "@/utils/request";
const router = useRouter();
const loading = ref(false);
const postForm = reactive({
title: '',
content: '',
tags: []
});
const errors = reactive({
title: '',
content: '',
tags: ''
});
const availableTags = ref(tagList);
const toggleTag = (tag) => {
const index = postForm.tags.indexOf(tag.id);
if (index === -1) {
if (postForm.tags.length < 1) {
postForm.tags.push(tag.id);
} else {
Message.warning('最多只能选择1个标签');
}
} else {
postForm.tags.splice(index, 1);
}
};
const validateForm = () => {
let isValid = true;
errors.title = '';
errors.content = '';
errors.tags = '';
if (!postForm.title.trim()) {
errors.title = '请输入帖子标题';
isValid = false;
} else if (postForm.title.length > 100) {
errors.title = '标题长度不能超过100个字符';
isValid = false;
}
if (!postForm.content.trim()) {
errors.content = '请输入帖子内容';
isValid = false;
}
if (postForm.tags.length === 0) {
errors.tags = '请至少选择一个标签';
isValid = false;
}
return isValid;
};
const handleEditorChange = (text) => {
postForm.content = text;
};
const handleSubmit = async () => {
if (!validateForm()) return;
try {
loading.value = true;
// 将标签ID转换为标签名称
const tagNames = postForm.tags.map(tagId =>
availableTags.value.find(tag => tag.id === tagId)?.name
).filter(name => name); // 过滤掉可能的undefined
const data = {
title: postForm.title,
content: postForm.content,
tags: tagNames
};
if (isEdit.value) {
await postApi.update(postId.value, data);
Message.success('更新成功');
} else {
await postApi.create(data);
Message.success('发布成功');
}
await router.push('/');
} catch (error) {
console.error(isEdit.value ? '更新失败:' : '发布失败:', error);
Message.error(error.message || (isEdit.value ? '更新失败,请稍后重试' : '发布失败,请稍后重试'));
} finally {
loading.value = false;
}
};
const route = useRoute();
const isEdit = ref(false);
const postId = ref(null);
// 在 onMounted 中检查是否是编辑模式
onMounted(async () => {
// 检查是否是编辑模式
if (route.query.edit === 'true' && route.query.postId) {
isEdit.value = true;
postId.value = route.query.postId;
await fetchPostDetail();
}
});
// 获取帖子详情
const fetchPostDetail = async () => {
try {
const res = await postApi.getDetail(postId.value);
postForm.title = res.data.title;
postForm.content = res.data.content;
// 将标签名称转换为对应的标签ID
postForm.tags = res.data.tags.map(tagName =>
availableTags.value.find(tag => tag.name === tagName)?.id
).filter(id => id); // 过滤掉可能的undefined
} catch (error) {
console.error('获取帖子详情失败:', error);
Message.error('获取帖子详情失败');
}
};
// 处理图片上传
const handleUploadImage = async (event, insertImage, files) => {
try {
const file = files[0];
// 检查文件类型
if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
Message.error('只支持 jpg、png、gif 格式的图片');
return;
}
// 检查文件大小(最大 5MB)
if (file.size > 5 * 1024 * 1024) {
Message.error('图片大小不能超过 5MB');
return;
}
const formData = new FormData();
formData.append('file', file);
const response = await request({
url: "/api/upload/image",
method: "post",
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (response.data.url) {
insertImage({
url: baseURL + response.data.url,
desc: file.name
});
Message.success('图片上传成功');
}
} catch (error) {
console.error('图片上传失败:', error);
Message.error('图片上传失败,请稍后重试');
}
};
</script>