当前位置: 首页 > news >正文

个人博客系统

个人博客系统

一个基于 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.文章阅读页面
在这里插入图片描述

调整为个人博客网站操作说明

  1. 只需要将前台的文章书写页面屏蔽掉,然后将文章列表页面的修改按钮屏蔽掉
  2. 后台的相应文章修改和新增接口也屏蔽掉,即转换为个人博客网站

源码下载

个人博客系统

核心源码

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>

相关文章:

  • 【转载翻译】Open3D和PCL的一些比较
  • 如何协调跨部门资源争夺
  • 【KWDB 创作者计划】_产品技术解读_1
  • 谈谈 typescript 中 namespace 的理解
  • AQchat
  • Vite配置postcss-px-to-viewport
  • 代理模式简述
  • 贪心算法(19)(java)重构字符串
  • 银河麒麟系统添加开机自启动
  • 【AI】使用Huggingface模型实现文本内容摘要器
  • DeepSeek 接入 Word 完整教程
  • shell 编程之循环语句
  • UNet深度学习实战遥感图像语义分割
  • 孟加拉slot游戏出海代投FB脸书广告策略
  • HTTP协议入门
  • c# 委托和事件的区别及联系,Action<T1,T2>与Func<T1,T2>的区别
  • RTX 5060 Ti 3DMark跑分首次流出:比RTX 4060 Ti快20%
  • JVM——运行时数据区
  • Linux内核中struct net_protocol的early_demux字段解析
  • 谷歌A2A与Anthropic MCP: AI 智能体互补双协议
  • 韩国一战机飞行训练中掉落机炮吊舱和空油箱
  • 解除近70家煤电厂有毒物质排放限制,特朗普能重振煤炭吗?
  • 上海一季度人民币贷款增4151亿,住户存款增3134亿
  • 礼来公布口服降糖药积极结果,或年底前提交用于体重管理上市申请
  • “替父追债被判寻衅滋事案”从犯获国赔,该案司法机关共赔偿217万元
  • 网文书单|女频网文只是过家家?姐姐们搞事业干得飞起