全栈开发实战(二)——简易博客社区前端搭建

项目展示视频
项目Github地址

(一)项目准备

在开始我们的项目前,请确保你已安装好node.js及vue3.js,并配置好相应的编辑器,本项目所使用的编辑器为Visual Studio Code

1. 创建项目

在终端输入以下语句使用vite创建项目blog_client:

npm init vite@latest

cd进入输入以下语句安装必要的依赖:

npm install

输入以下语句运行项目:

npm run dev

2. 模块安装

本项目所使用的模块如下:

模块 说明
axios 基于promise的HTTP库,用于http请求
pinia Vue的存储库,它允许您跨组件、页面共享状态
sass CSS的开发工具,提供许多便利写法
vue-router Vue.js官方的路由插件
naive-ui Vue3的组件库
wangeditor 富文本编辑器

从终端进入client文件夹,输入:

npm install axios
npm install pinia
npm install sass

Vue Router 安装文档

npm install vue-router@4

Naive UI安装文档

npm i -D naive-ui
npm i -D vfonts
npm i -D @vicons/ionicons5

wangEditor安装文档

npm install @wangeditor/editor-for-vue@next --save

安装上述模块

3. 修改全局格式文件(非必要)

将src/style.css修改为(当然,背景颜色可以自由选择):

body {background-color: #FCFAF7;margin: 0;padding: 0;
}

4. 新建文件夹存放图片(非必要)

在assets文件夹下新建文件夹image,该文件夹存放一些显示在页面上的图片

5. 引入基本的模块

在main.js中引入相关模块:

import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
import naive from "naive-ui"; // 引入ui框架
import { createDiscreteApi } from "naive-ui"; // 引入createDiscreteApi
import { createPinia } from "pinia"; // 引入pinia
import { router } from "./common/router"; // 引入路由
import axios from "axios"; // 引入axios
import { UserStore } from "./stores/UserStore" // 引入UserStoreaxios.defaults.baseURL = "http://localhost:8080"; // 服务端地址全局配置
const { message, notification, dialog } = createDiscreteApi(["message", "notification", "dialog"])const app = createApp(App);app.provide("axios", axios); // 将axios全局放入
app.provide("message", message)
app.provide("notification", notification)
app.provide("dialog", dialog)
app.provide("serverUrl", axios.defaults.baseURL)app.use(naive); // 引入ui框架
app.use(createPinia()); // 引入piniaapp.use(router); // 引入路由
app.mount("#app");

在src文件夹下新建文件夹common和views,在common文件夹下创建文件router.js,引入路由:

import { createRouter, createWebHashHistory } from "vue-router";let routes = [
]const router = createRouter({history: createWebHashHistory(),routes,
});export { router, routes }

修改App.vue如下:

<template><router-view ></router-view>
</template><script setup></script><style scoped></style>

(二)登录注册页

该页面用到的组件为表单Form表单 Form - Naive UI

在view文件夹下新建文件Register.vue,编写注册页,我们获取用户的用户名、手机号和密码并传给后端

<template><div class="background"><img src="../assets/image/rectangle1.png" class="rectangle1" /><img src="../assets/image/rectangle2.png" class="rectangle2" /><img src="../assets/image/rectangle3.png" class="rectangle3" /><img src="../assets/image/rectangle4.png" class="rectangle4" /><img src="../assets/image/person.png" class="person" /></div><div class="board"><div><div @click="toLogin" class="button2"><div style="position: absolute;left:22px;">登录</div></div><div class="button1"><div style="position:absolute;left:22px;">注册</div></div>     </div><n-form ref="formRef" :rules="rules" :model="user"><n-form-item path="userName" style="position:absolute;left:70px;top:120px;width:350px;"><n-input v-model:value="user.userName" size="large" round placeholder="用户名"/> </n-form-item><n-form-item path="phoneNumber" style="position:absolute;left:70px;top:190px;width:350px;"><n-input v-model:value="user.phoneNumber" size="large" round placeholder="手机号"/> </n-form-item><n-form-item path="password" style="position:absolute;left:70px;top:260px;width:350px;"><n-input v-model:value="user.password" size="large" round type = "password" placeholder="密码"/> </n-form-item><n-form-item path="repeatPassword" style="position:absolute;left:70px;top:330px;width:350px;"><n-input v-model:value="user.repeatPassword" size="large" round type = "password" placeholder="重新输入密码"/> </n-form-item></n-form><div @click="submit" class="button3"><div style="left: auto;right: auto;text-align: center;">注册</div></div></div>
</template><script setup>
import {ref,reactive,inject} from 'vue'
import {useRouter, useRoute} from 'vue-router'
const router = useRouter()
const route = useRoute()const axios = inject("axios")
const message = inject("message")const formRef = ref(null)
const user = reactive({userName: "",phoneNumber: "",password:"",repeatPassword:"",
})function validatePasswordSame(rule, value) {return value == user.password;
}let rules = {userName: [{ required: true, message: "请输入用户名", trigger: "blur" },{ min: 3, max: 20, message: "用户名长度在 3 到 20 个字符", trigger: "blur"},],phoneNumber: [{ required: true, message: "请输入手机号", trigger: "blur" },{ min: 11, max: 11, message: "手机号为 11 位", trigger: "blur"},],password: [{ required: true, message: "请输入密码", trigger: "blur" },{ min: 6, max: 20, message: "密码长度在 6 到 20 个字符", trigger: "blur"},    ],repeatPassword: [{ required: true, message: "请重新输入密码", trigger: "blur" }, { validator: validatePasswordSame, message: "两次输入的密码不一致", trigger: "blur"},],
}function submit() {formRef.value?.validate((errors) => {if (errors) {message.error("注册失败")} else {register();}    })
}const register = async() => {let res = await axios.post("/register", {userName: user.userName,phoneNumber: user.phoneNumber,password: user.password})console.log(res)if (res.data.code == 200) {message.success(res.data.msg)router.push({path: "login",query: {phoneNumber: user.phoneNumber,password: user.password}})} else {message.error(res.data.msg)}
}const toLogin = () => {router.push("/login")
}</script><style lang="scss" scoped>
.background {.rectangle1 {position: absolute;margin-left: -160px;top: -320px;z-index:-1;}.rectangle2 {position: absolute;left: 650px;top: 0px;z-index:-1;}.rectangle3 {position: absolute;left: 800px;top: -100px;z-index:-1;}.rectangle4 {position: absolute;left: 1100px;top: 450px;z-index:-1;}.person {position: absolute;left: 80px;top: 70px;z-index:-1;}
}
.person {position: absolute;left: 80px;top: 70px;z-index:-1;
}
.board {position: absolute;top: 95px;right: 235px;width: 500px;height: 550px;border-radius: 20px;box-shadow: 0px 20px 50px #D3D4D8;background-color: white;z-index: 0;.button1 {position: absolute;top: 75px;left: 150px;width: 80px;height: 40px;border-radius: 20px;background-color: #7B3DE0;line-height: 40px;font-size: 16px;color: white;  cursor: default;      }.button2 {position: absolute;top: 75px;left: 70px;width: 160px;height: 40px;border-radius: 20px;background-color: #F1EBFB;  line-height: 40px;font-size: 16px;color: black;cursor: pointer;}.button3 {position: absolute;top: 430px;left: 70px;width: 350px;height: 50px;border-radius: 20px;background-color: #7B3DE0;  line-height: 50px;font-size: 16px;color: white;cursor: pointer;}
}
</style>

由于用户登录完成后后端会返回一个token,我们需要将这个token保存起来,以便传给其他接口

在src文件夹下新建文件夹stores,在stores文件夹下新建文件UserStore.js,写入以下代码定义存储的内容

import { defineStore } from "pinia"; // 引入piniaexport const UserStore = defineStore("admin", {state: () => {return {token: "",};},actions: {},getters: {},
});

在view文件夹下新建文件Login.vue,编写登录页,我们获取用户的手机号和密码传给后端,登录成功后存储后端传过来的token

<template><div class="background"><img src="../assets/image/rectangle1.png" class="rectangle1" /><img src="../assets/image/rectangle2.png" class="rectangle2" /><img src="../assets/image/rectangle3.png" class="rectangle3" /><img src="../assets/image/rectangle4.png" class="rectangle4" /><img src="../assets/image/person.png" class="person" /></div><div class="board"><div><div @click="toRegister" class="button2"><div style="position: absolute;right:22px;">注册</div></div><div class="button1"><div style="position:absolute;left:22px;">登录</div></div>     </div><n-form ref="formRef" :rules="rules" :model="user"><n-form-item path="phoneNumber" style="position:absolute;left:70px;top:150px;width:350px;"><n-input v-model:value="user.phoneNumber" size="large" round placeholder="手机号"/> </n-form-item><n-form-item path="password" style="position:absolute;left:70px;top:230px;width:350px;"><n-input v-model:value="user.password" size="large" round type = "password" placeholder="密码"/> </n-form-item></n-form><n-checkbox v-model:checked="user.rember" label="记住密码" style="position:absolute;left:70px;top:330px;"/><div @click="submit" class="button3"><div style="left: auto;right: auto;text-align: center;">登录</div></div></div>
</template><script setup>
import {ref,reactive,inject} from 'vue'
import {UserStore} from '../stores/UserStore'import {useRouter, useRoute} from 'vue-router'
const router = useRouter()
const route = useRoute()const axios = inject("axios")
const message = inject("message")
const userStore = UserStore()const formRef = ref(null)
const user = reactive({phoneNumber: localStorage.getItem("phoneNumber") || route.query.phoneNumber || "",password: localStorage.getItem("password") || route.query.password || "" ,rember: localStorage.getItem("rember") == 1 || false
})let rules = {phoneNumber: [{ required: true, message: "请输入手机号", trigger: "blur" },{ min: 11, max: 11, message: "手机号为 11 位", trigger: "blur"},],password: [{ required: true, message: "请输入密码", trigger: "blur" },{ min: 6, max: 20, message: "密码长度在 6 到 20 个字符", trigger: "blur"},    ]
}function submit() {formRef.value?.validate((errors) => {if (errors) {message.error("注册失败")} else {login();}    })
}const login = async() => {let res = await axios.post("/login", {phoneNumber: user.phoneNumber,password: user.password})console.log(res)if (res.data.code == 200) {userStore.token = res.data.data.tokenif (user.rember) {localStorage.setItem("phoneNumber", user.phoneNumber)localStorage.setItem("password", user.password)localStorage.setItem("rember", user.rember? 1: 0)} else {localStorage.removeItem("phoneNumber")localStorage.removeItem("password")localStorage.setItem("rember", user.rember? 1: 0)}        router.push("/")message.success(res.data.msg)        } else {message.error(res.data.msg)}
}const toRegister = () => {router.push("/register")
}</script><style lang="scss" scoped>
.background {.rectangle1 {position: absolute;left: -160px;top: -320px;z-index:-1;}.rectangle2 {position: absolute;left: 650px;top: 0px;z-index:-1;}.rectangle3 {position: absolute;left: 800px;top: -100px;z-index:-1;}.rectangle4 {position: absolute;left: 1100px;top: 450px;z-index:-1;}.person {position: absolute;left: 80px;top: 70px;z-index:-1;}
}
.board {position: absolute;top: 95px;right: 235px;width: 500px;height: 550px;border-radius: 20px;box-shadow: 0px 20px 50px #D3D4D8;background-color: white;z-index: 0;.button1 {position: absolute;top: 75px;left: 70px;width: 80px;height: 40px;border-radius: 20px;background-color: #7B3DE0;line-height: 40px;font-size: 16px;color: white;  cursor: default;      }.button2 {position: absolute;top: 75px;left: 70px;width: 160px;height: 40px;border-radius: 20px;background-color: #F1EBFB;  line-height: 40px;font-size: 16px;color: black;cursor: pointer;}.button3 {position: absolute;top: 400px;left: 70px;width: 350px;height: 50px;border-radius: 20px;background-color: #7B3DE0;  line-height: 50px;font-size: 16px;color: white;cursor: pointer;}
}
</style>

编写完登录注册页后,将其加入路由,修改router.js

import { createRouter, createWebHashHistory } from "vue-router";let routes = [{ path: "/login", component: () => import("../views/Login.vue") },{ path: "/register", component: () => import("../views/Register.vue") },
]const router = createRouter({history: createWebHashHistory(),routes,
});export { router, routes }

修改App.vue

<template><router-view ></router-view>
</template><script setup></script><style scoped></style>

(三)顶栏组件

顶栏组件用到头像组件头像 Avatar - Naive UI和按钮组件按钮 Button - Naive UI

接下来我们编写一个顶栏组件,该组件可以跳转至主页、个人信息页、登录页以及发布文章页

我们先在view文件夹下新建文件MainFrame.vue、Myself.vue、Others.vue、Publish.vue、Update.vue、Detail.vue,写入空页面并添加进路由,便于跳转

import { createRouter, createWebHashHistory } from "vue-router";let routes = [{ path: "/login", component: () => import("../views/Login.vue") },{ path: "/register", component: () => import("../views/Register.vue") },{ path: "/", component: () => import("../views/MainFrame.vue") },{ path: "/publish", component: () => import("../views/Publish.vue") },{ path:"/myself", component: () => import("../views/Myself.vue") },{ path:"/others", component: () => import("../views/Others.vue") },{ path:"/detail", component: () => import("../views/Detail.vue") },{ path:"/update", component: () => import("../views/Update.vue") },
]const router = createRouter({history: createWebHashHistory(),routes,
});export { router, routes }

然后在components文件夹下新建文件TopBar.vue,编写顶栏,顶栏渲染时向后端接口/user获取头像,若用户已登录,将成功获取用户头像,否则可跳转至登录页

<template><div class="container"><div class="topbar"><div class="bigtitle" @click="toMain">首页</div><n-dropdown v-if="login" trigger="hover" :options="options" @select="handleSelect"><n-avatar @click="toHome" round size="medium" :src=user.avatarUrl style="position: absolute; right: 190px; top: 8px; cursor: pointer;"/></n-dropdown><div v-if="!login" class="smalltitle" @click="toLogin">登录/注册</div><div style="position: absolute; right: 50px; top: 8px"><n-button round color="#7B3DE0" @click="toPublish">发布文章</n-button></div>      </div></div>
</template><script setup>
import {ref,reactive,inject, onMounted} from 'vue'import {useRouter, useRoute} from 'vue-router'
const router = useRouter()
const route = useRoute()const serverUrl = inject("serverUrl")
const axios = inject("axios")
const message = inject("message")const options = reactive([{label: "退出登录", key: "login"}])
const login = ref(false)
const user = reactive({avatarUrl: "",id: 0
})onMounted(() => {loadAvatar()
})const loadAvatar= async() => {let res = await axios.get("/user")    console.log(res)if (res.data.code == 200) {user.avatarUrl = serverUrl + res.data.data.avataruser.id = res.data.data.idlogin.value = true}
} const toMain = () => {router.push("/")
}const toLogin = () => {router.push("/login")
}const toHome = () => {router.push({path: "/myself",query: {id: user.id}})
}const toPublish = () => {if (login.value == false) {message.warning("请先登录")} else {router.push("/publish")   }
}const handleSelect = (key) => {router.push("/" + String(key))
}</script><style lang="scss" scoped>
.container {.topbar {position: sticky;top: 0;height: 50px;background: white;box-shadow: 0px 1px 5px #D3D4D8;.bigtitle {position: absolute;font-size: 20px;left: 50px;line-height: 50px;color: #7B3DE0;cursor: pointer;}.smalltitle {position: absolute;font-size: 16px;right: 175px;line-height: 50px;color: #7B3DE0;cursor: pointer;            }}
}
</style>

修改main.js,添加拦截器传token,即每个页面都向后端传token,无论后端需不需要

import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
import naive from "naive-ui"; // 引入ui框架
import { createDiscreteApi } from "naive-ui"; // 引入createDiscreteApi
import { createPinia } from "pinia"; // 引入pinia
import { router } from "./common/router"; // 引入路由
import axios from "axios"; // 引入axios
import { UserStore } from "./stores/UserStore" // 引入UserStoreaxios.defaults.baseURL = "http://localhost:8080"; // 服务端地址全局配置
const { message, notification, dialog } = createDiscreteApi(["message", "notification", "dialog"])const app = createApp(App);app.provide("axios", axios); // 将axios全局放入
app.provide("message", message)
app.provide("notification", notification)
app.provide("dialog", dialog)
app.provide("serverUrl", axios.defaults.baseURL)app.use(naive); // 引入ui框架
app.use(createPinia()); // 引入piniaconst userStore = UserStore()
// 拦截器传token
axios.interceptors.request.use((config) => {config.headers.authorization = `Bearer ${userStore.token}`return config
})app.use(router); // 引入路由
app.mount("#app");

修改router.js添加路由

import { createRouter, createWebHashHistory } from "vue-router";let routes = [{ path: "/login", component: () => import("../views/Login.vue") },{ path: "/register", component: () => import("../views/Register.vue") },{ path: "/", component: () => import("../views/MainFrame.vue") },{ path: "/publish", component: () => import("../views/Publish.vue") },{ path:"/myself", component: () => import("../views/Myself.vue") },
]const router = createRouter({history: createWebHashHistory(),routes,
});export { router, routes }

(四)个人信息页

个人信息页用到的组件有卡片卡片 Card - Naive UI和模态框模态框 Modal - Naive UI,以及图标图标 Icon - Naive UI

用户点击头像可进入用户信息页,登录用户查看自身与他人的信息页渲染有所不同,自身的个人信息页有修改信息按键,而他人的个人信息页有关注按键

Myself.vue:

<template><div><div style="position: fixed; top: 0; height: 50px; width: 100%; z-index: 999; "><TopBar></TopBar></div><div class="card"><div style="position: absolute; left: 40px; bottom: 20px"><n-avatar round :size="120" :src=user.avatarUrl :bordered=true /></div><div style="position: absolute; top: 25px;left: 200px; font-size: 20px;">{{user.name}}</div><div style="position: absolute; top: 70px;left: 200px;"><text style="font-weight:bold; font-size: 20px;">{{user.number}}</text><text style="margin-left: 5px; font-size: 14px;">文章</text><text style="font-weight:bold; font-size: 20px; margin-left: 20px;">{{user.fans}}</text><text style="margin-left: 5px; font-size: 14px;">粉丝</text></div><n-dropdown trigger="hover" :options="options" @select="handleSelect"><n-button style="position: absolute; right: 40px; top: 25px;" round ghost color="#7B3DE0">修改资料</n-button></n-dropdown></div><n-modal v-model:show="showAvatarModal"><div style="width: 600px; height: 320px; background: white;"><n-card title="修改头像" :bordered="false"><n-uploadmultipledirectory-dnd:max="1"@before-upload="beforeUpload":custom-request="customRequest"><n-upload-dragger><div style="margin-bottom: 12px"><n-icon size="48" :depth="3"><archive-icon /></n-icon></div><n-text style="font-size: 16px">点击或者拖动图片到此处</n-text></n-upload-dragger></n-upload></n-card><div style="position: absolute; right: 90px; bottom: 20px;"><n-button type="default" @click="closeAvatarModal">取消</n-button></div><div style="position: absolute; right: 20px; bottom: 20px;"><n-button v-if="newAvatar" @click="modifyAvatar" type="primary">确认</n-button><n-button v-else type="primary" disabled>确认</n-button></div></div></n-modal><n-modal v-model:show="showNameModal"><div style="width: 440px; height: 185px; background: white;"><n-card title="修改用户名" :bordered="false"><div style="width:350px;"><n-input v-model:value="newName" size="large" round type="text" placeholder="请输入用户名" /></div></n-card><div style="position: absolute; right: 90px; bottom: 20px;"><n-button type="default" @click="closeNameModal">取消</n-button></div><div style="position: absolute; right: 20px; bottom: 20px;"><n-button type="primary" @click="modifyName">确认</n-button></div>                </div></n-modal><div class="tabs"><n-card><n-tabs type="line" ><n-tab-pane  name="articles" tab="我的文章"><div v-for="(article,index) in articles" style="margin-bottom:15px"><n-card v-if="article.head_image" @click="toDetail(article)" style="cursor: pointer;" hoverable ><n-image height="135" width="200" :src=serverUrl+article.head_image style="float: left" /><div style="position: absolute; left: 240px; width: 690px;"><text  style="font-weight:bold; font-size: 20px;">{{article.title}}</text><p >{{article.content+"..."}}</p><div style="position: absolute; margin-top: 10px;">发布时间:{{article.created_at}}</div></div></n-card><n-card v-else @click="toDetail(article)" style="cursor: pointer;" hoverable ><div style="height: 140px; "><text style="font-weight:bold; font-size: 20px;">{{article.title}}</text><p >{{article.content+"..."}}</p><div style="position: absolute; margin-top: 10px;">发布时间:{{article.created_at}}</div>                           </div></n-card></div></n-tab-pane><n-tab-pane  name="collects" tab="我的收藏"><div v-for="(col,index) in collects" style="margin-bottom:15px"><n-card v-if="col.head_image" @click="toDetail(col)" style="cursor: pointer;" hoverable ><n-image height="135" width="200" :src=serverUrl+col.head_image style="float: left" /><div style="position: absolute; left: 240px; width: 690px;"><text style="font-weight:bold; font-size: 20px;">{{col.title}}</text><p>{{col.content+"..."}}</p><div style="position: absolute; margin-top: 10px;">发布时间:{{col.created_at}}</div></div></n-card><n-card v-else style="cursor: pointer;" hoverable ><div style="height: 140px; "><text @click="toDetail(col)" style="font-weight:bold; font-size: 20px;">{{col.title}}</text><p @click="toDetail(col)" >{{col.content+"..."}}</p><div style="position: absolute; margin-top: 10px;">发布时间:{{col.created_at}}</div>                        </div></n-card></div></n-tab-pane><n-tab-pane name="following" tab="我的关注"><div v-for="(fol,index) in following" style="margin-bottom:15px"><n-card><n-avatar @click="toOtherUser(fol)" round size="large" :src=serverUrl+fol.avatar style="float: left; cursor: pointer;" /><text style="position: absolute; left: 90px; top: 25px; font-size: 20px;">{{fol.userName}}</text></n-card></div></n-tab-pane></n-tabs></n-card></div></div>
</template><script setup>
import {ref,reactive,inject, onMounted} from 'vue'
import TopBar from '../components/TopBar.vue'
import { ArchiveOutline as ArchiveIcon} from "@vicons/ionicons5"import {useRouter, useRoute} from 'vue-router'
const router = useRouter()
const route = useRoute()const serverUrl = inject("serverUrl")
const axios = inject("axios")
const message = inject("message")
const options = reactive([{label: "修改头像", key: "avatar"},{label: "修改用户名", key: "name"},
])
const user = reactive({self: false,avatarUrl: "",name: "",number: 0,fans: 0,id: 0
})
const newUrl = ref("")
const newAvatar = ref(false)
const newName = ref("")
const showAvatarModal = ref(false)
const showNameModal = ref(false)
const articles = ref([])
const collects = ref([])
const following = ref([])onMounted(() => {loadDetailedInfo()
})const loadDetailedInfo= async() => {let res = await axios.get("user/detailedInfo/" + route.query.id)        console.log(res)if (res.data.code == 200) {user.self = res.data.data.selfuser.avatarUrl = serverUrl + res.data.data.avataruser.name = res.data.data.nameuser.number = res.data.data.articles.lengthuser.fans = res.data.data.fansuser.id = res.data.data.idarticles.value = res.data.data.articlescollects.value = res.data.data.collectsfollowing.value = res.data.data.followingnewName.value =  user.name}
} const handleSelect = (key) => {if (String(key) == "avatar") {showAvatarModal.value = true} if (String(key) == "name") {showNameModal.value = true}
}const beforeUpload = async(data) => {if (data.file.file?.type !== "image/png") {message.error("只能上传png格式的图片")return false;}return true;
}const customRequest = async({file}) => {const formData = new FormData()formData.append('file', file.file)let res = await axios.post("/upload", formData)console.log(res)newUrl.value = res.data.data.filePathnewAvatar.value = true
}const modifyAvatar = async() => {let res = await axios.put("user/avatar/" + route.query.id,{avatar: newUrl.value,})     console.log(res) if (res.data.code == 200) {message.success(res.data.msg)    showAvatarModal.value = false  loadDetailedInfo()}  else {message.error(res.data.msg)  }
}const modifyName = async() => {let res = await axios.put("user/name/" + route.query.id,{userName: newName.value,})     console.log(res) if (res.data.code == 200) {message.success(res.data.msg)    showNameModal.value = false  loadDetailedInfo()}  else {message.error(res.data.msg)  }
}const closeAvatarModal = () => {showAvatarModal.value = false
}const closeNameModal = () => {showNameModal.value = false
}const toOtherUser = (fol) => {router.push({path: "/others",query: {id: fol.id}})
}const toDetail = (article) => {router.push({path: "/detail",query: {id: article.id}})
}</script><style lang="scss" scoped>
.card {position: absolute;top: 100px;left: 0;right: 0;margin: auto;width: 1000px;height: 130px;background: white;  box-shadow: 0px 1px 3px #D3D4D8; border-radius: 5px;
}
.tabs {position: absolute;top: 250px;left: 0;right: 0;margin: auto;width: 1000px;height: auto;background: white;  box-shadow: 0px 1px 3px #D3D4D8; border-radius: 5px;
}
.cardInfo {float: right;width: 80%;
}
</style>

Others.vue

<template><div><div style="position: fixed; top: 0; height: 50px; width: 100%; z-index: 999; "><TopBar></TopBar></div><div class="card"><div style="position: absolute; left: 40px; bottom: 20px"><n-avatar round :size="120" :src=user.avatarUrl :bordered=true /></div><div style="position: absolute; top: 25px;left: 200px; font-size: 20px;">{{user.name}}</div><div style="position: absolute; top: 70px;left: 200px;"><text style="font-weight:bold; font-size: 20px;">{{user.number}}</text><text style="margin-left: 5px; font-size: 14px;">文章</text><text style="font-weight:bold; font-size: 20px; margin-left: 20px;">{{user.fans}}</text><text style="margin-left: 5px; font-size: 14px;">粉丝</text></div><n-button v-if=!followed @click="newFollow" style="position: absolute; right: 40px; top: 25px; cursor: pointer;" round ghost color="#ED4557"><template #icon><n-icon><heart-outline /></n-icon></template>关注</n-button><n-button v-else @click="unFollow" style="position: absolute; right: 40px; top: 25px; cursor: pointer;" round ghost color="#ED4557"><template #icon><n-icon><heart /></n-icon></template>已关注</n-button></div><div class="tabs"><n-card><n-tabs type="line" ><n-tab-pane  name="articles" tab="TA的文章"><div v-for="(article,index) in articles" style="margin-bottom:15px"><n-card v-if="article.head_image" @click="toDetail(article)" style="cursor: pointer;" hoverable ><n-image height="135" width="200" :src=serverUrl+article.head_image style="float: left" /><div style="position: absolute; left: 240px; width: 690px;"><text style="font-weight:bold; font-size: 20px;">{{article.title}}</text><p>{{article.content+"..."}}</p><div style="position: absolute; margin-top: 10px;">发布时间:{{article.created_at}}</div></div></n-card><n-card v-else style="cursor: pointer;" hoverable ><div style="height: 140px; "><text @click="toDetail(article)" style="font-weight:bold; font-size: 20px;">{{article.title}}</text><p @click="toDetail(article)" >{{article.content+"..."}}</p><div style="position: absolute; margin-top: 10px;">发布时间:{{article.created_at}}</div>                        </div></n-card></div></n-tab-pane><n-tab-pane  name="collects" tab="TA的收藏"><div v-for="(col,index) in collects" style="margin-bottom:15px"><n-card v-if="col.head_image" @click="toDetail(col)" style="cursor: pointer;" hoverable ><n-image height="135" width="200" :src=serverUrl+col.head_image style="float: left" /><div style="position: absolute; left: 240px; width: 690px;"><text style="font-weight:bold; font-size: 20px;">{{col.title}}</text><p>{{col.content+"..."}}</p><div style="position: absolute; margin-top: 10px;">发布时间:{{col.created_at}}</div></div></n-card><n-card v-else style="cursor: pointer;" hoverable ><div style="height: 140px; "><text @click="toDetail(col)" style="font-weight:bold; font-size: 20px;">{{col.title}}</text><p @click="toDetail(col)" >{{article.content+"..."}}</p><div style="position: absolute; margin-top: 10px;">发布时间:{{col.created_at}}</div>                        </div></n-card></div></n-tab-pane><n-tab-pane name="following" tab="TA的关注"><div v-for="(fol,index) in following" style="margin-bottom:15px"><n-card><n-avatar @click="toOtherUser(fol)" round size="large" :src=serverUrl+fol.avatar style="float: left; cursor: pointer;" /><text style="position: absolute; left: 90px; top: 25px; font-size: 20px;">{{fol.userName}}</text></n-card></div></n-tab-pane></n-tabs></n-card></div></div>
</template><script setup>
import {ref,reactive,inject, onMounted} from 'vue'
import TopBar from '../components/TopBar.vue'
import {HeartOutline} from '@vicons/ionicons5'
import {Heart} from '@vicons/ionicons5'import {useRouter, useRoute} from 'vue-router'
const router = useRouter()
const route = useRoute()const serverUrl = inject("serverUrl")
const axios = inject("axios")
const message = inject("message")const user = reactive({self: false,avatarUrl: "",name: "",number: 0,fans: 0,id: 0,loginId: 0,
})
const articles = ref([])
const collects = ref([])
const following = ref([])
const followed = ref(false)
const index = ref(0)onMounted(() => {loadDetailedInfo()
})const loadDetailedInfo = async() => {let res1 = await axios.get("user/detailedInfo/" + route.query.id)        console.log(res1)if (res1.data.code == 200) {user.self = res1.data.data.selfuser.avatarUrl = serverUrl + res1.data.data.avataruser.name = res1.data.data.nameuser.number = res1.data.data.articles.lengthuser.fans = res1.data.data.fansuser.id = res1.data.data.iduser.loginId = res1.data.data.loginIdarticles.value = res1.data.data.articlescollects.value = res1.data.data.collectsfollowing.value = res1.data.data.followinglet res2 = await axios.get("following/" + route.query.id) console.log(res2)if (res2.data.code == 200) {followed.value = res2.data.data.followedindex.value = res2.data.data.index}}
} const newFollow = async() => {let res1 = await axios.put("following/new/" + route.query.id)console.log(res1)  if (res1.data.code == 200) {message.warning("已关注", {showIcon: false})  loadDetailedInfo()  }
}const unFollow = async() => {let res1 = await axios.delete("following/" + index.value)console.log(res1)  if (res1.data.code == 200) {message.warning("取消关注", {showIcon: false})  loadDetailedInfo()  }
}const toOtherUser = (fol) => {console.log(fol.id, user.loginId)if (fol.id == user.loginId) {router.push({path: "/myself",query: {id: fol.id}})   } else {router.push({path: "/others",query: {id: fol.id}})}
}const toDetail = (article) => {router.push({path: "/detail",query: {id: article.id}})
}</script><style lang="scss" scoped>
.card {position: absolute;top: 100px;left: 0;right: 0;margin: auto;width: 1000px;height: 130px;background: white;  box-shadow: 0px 1px 3px #D3D4D8; border-radius: 5px;
}
.tabs {position: absolute;top: 250px;left: 0;right: 0;margin: auto;width: 1000px;height: auto;background: white;  box-shadow: 0px 1px 3px #D3D4D8; border-radius: 5px;
}
.cardInfo {float: right;width: 80%;
}
</style>

(五)主页

用户在主页的输入框文本输入 Input - Naive UI输入关键词,通过选择器弹出选择 Popselect - Naive UI选择分类,通过分页器分页 Pagination - Naive UI分页

MainFrame.vue

<template><div><div style="position: fixed; top: 0; height: 50px; width: 100%; z-index: 999; "><div class="card"><n-popselect @update:value="searchByCategory" v-model:value="selectedCategory" :options="categoryOptions" trigger="click"><n-button text style="position:absolute; left: 50px; top: 22px; font-size: 18px;">{{categoryName}}</n-button></n-popselect><n-input v-model:value="pageInfo.keyword" round placeholder="请输入关键字" style="position:absolute; left: 125px; top: 15px; width: 1000px; background-color: #F3F0F9;" /><n-button @click="loadArticles(0)" round color="#7B3DE0" style="position:absolute; left: 1150px; top: 15px;"><template #icon><n-icon><search /></n-icon></template>搜索</n-button></div>            </div><div style="position: fixed; top: 0; height: 50px; width: 100%; z-index: 999; "><TopBar></TopBar></div><div class="tabs"><n-card><div v-for="(article,index) in articleList" style="margin-bottom:15px"><n-card v-if="article.head_image" @click="toDetail(article)" style="cursor: pointer;" hoverable ><n-image width="200" :src=serverUrl+article.head_image style="float: left" /><div style="position: absolute; left: 240px; width: 690px;"><text style="font-weight:bold; font-size: 20px;">{{article.title}}</text><p>{{article.content+"..."}}</p><div style="position: absolute; margin-top: 10px;">发布时间:{{article.created_at}}</div></div></n-card><n-card v-else style="cursor: pointer;" hoverable ><div style="height: 140px; "><text @click="toDetail(article)" style="font-weight:bold; font-size: 20px;">{{article.title}}</text><p @click="toDetail(article)" >{{article.content+"..."}}</p><div style="position: absolute; margin-top: 10px;">发布时间:{{article.created_at}}</div>                        </div></n-card></div><n-pagination @update:page="loadArticles" v-model:page="pageInfo.pageNum" :page-count="pageInfo.pageCount" /></n-card></div></div></template><script setup>
import TopBar from '../components/TopBar.vue'
import {ref,reactive,inject,onMounted,computed} from 'vue'
import {Search} from '@vicons/ionicons5'import {useRouter, useRoute} from 'vue-router'
const router = useRouter()
const route = useRoute()const serverUrl = inject("serverUrl")
const axios = inject("axios")
const message = inject("message")const selectedCategory = ref(0)
const categoryOptions = ref([])
const articleList = ref([])
const pageInfo = reactive({pageNum:1,pageSize:5,pageCount:0,count:0,keyword:"",categoryId:0
})onMounted(()=>{loadArticles()loadCategories()
})const loadArticles = async(pageNum = 0) =>{if (pageNum != 0){pageInfo.pageNum = pageNum;}let res = await axios.post(`/article/list?keyword=${pageInfo.keyword}&pageNum=${pageInfo.pageNum}&pageSize=${pageInfo.pageSize}&categoryId=${pageInfo.categoryId}`)console.log(res)if (res.data.code == 200) {articleList.value = res.data.data.article}pageInfo.count = res.data.data.count;pageInfo.pageCount = parseInt(pageInfo.count / pageInfo.pageSize) + (pageInfo.count % pageInfo.pageSize > 0 ? 1 : 0)console.log(pageInfo.pageNum, pageInfo.pageCount, pageInfo.count)
}const loadCategories = async() =>{let res = await axios.get("/category")console.log(res)categoryOptions.value = res.data.data.categories.map((item)=>{return {label:item.name,value:item.id}})
}const categoryName = computed(() => {let selectedOption = categoryOptions.value.find((option) => {return option.value == selectedCategory.value})console.log(selectedOption)return selectedOption ? selectedOption.label : ""
})const searchByCategory = (categoryId) => {pageInfo.categoryId = categoryIdpageInfo.pageNum = 1loadArticles()
}const toDetail = (article) => {router.push({path: "/detail",query: {id: article.id}})
}</script><style lang="scss" scoped>
.card {position: absolute;top: 50px;left: 0;right: 0;margin: auto;height: 60px;background: white;  box-shadow: 0px 1px 3px #D3D4D8; border-radius: 5px;
}
.tabs {position: absolute;top: 150px;left: 0;right: 0;margin: auto;width: 1000px;height: auto;background: white;  box-shadow: 0px 1px 3px #D3D4D8; border-radius: 5px;
}
</style>

(六)富文本编辑组件

https://www.wangeditor.com/v5/for-frame.html

在components文件夹下新建组件RichTextEditor,按照文档要求编写组件,由于上传本地视频较麻烦,这里将它屏蔽掉

<template><div><div style="border: 1px solid #ccc; margin-top: 10px"><Toolbar:editor="editorRef":defaultConfig="toolbarConfig":mode="mode"style="border-bottom: 1px solid #ccc"/><Editor:defaultConfig="editorConfig":mode="mode"v-model="valueHtml"style="height: 400px; overflow-y: hidden"@onCreated="handleCreated"@onChange="handleChange"/></div></div>
</template><script setup>
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import { ref,reactive,inject,onMounted,onBeforeUnmount, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'// 服务端地址
const serverUrl = inject("serverUrl")// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef();
// 模式
const mode = ref("default")
// 内容HTML
const valueHtml = ref("")
//菜单栏配置
const toolbarConfig = { excludeKeys:["uploadVideo"] };
// 编辑器配置
const editorConfig = { placeholder: '请输入内容...', MENU_CONF: {} };
// 上传图片
editorConfig.MENU_CONF = {}
editorConfig.MENU_CONF['uploadImage'] = {// 小于该值就插入 base64 格式(而不上传),默认为 0base64LimitSize: 10 * 1024, // 10kbserver: serverUrl+"/upload/rich_editor_upload",
}
// 插入图片
editorConfig.MENU_CONF['insertImage'] = {parseImageSrc:(src) => {console.log(serverUrl, src)if (src.indexOf("http") != 0){return `${serverUrl}${src}`}return src}
}
// 定义属性进行双向绑定
const props = defineProps({modelValue:{type:String,default:""}
})
// 定义抛出事件
const emit = defineEmits(["update:model-value"])
// 模拟 ajax 异步获取内容
onMounted(() => {setTimeout(() => {valueHtml.value = props.modelValueinitFinished = true      }, 10)
})
let initFinished = false
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {const editor = editorRef.valueif (editor == null) returneditor.destroy()
})// 编辑器回调函数
const handleCreated = (editor) => {editorRef.value = editor // 记录 editor 实例,重要!
}
const handleChange = (editor) => {if (initFinished) {emit("update:model-value", valueHtml.value) // 输入时往外抛}
};</script><style lang="scss" scoped></style>

(七)文章发布修改页

文章发布与修改页类似,不同的是修改页要先获取原文章数据再将其渲染

Publish.vue

<template><div class="topbar"><n-button @click="goback" strong quaternary round style="position: absolute; left: 50px; top: 7px; font-size: 24px;" color="#7B3DE0"><n-icon><return-up-back /></n-icon></n-button><text style="position:absolute; left: 200px; line-height: 50px; color: #383838">标题</text><n-input v-model:value="addArticle.title" round placeholder="请输入标题" style="position:absolute; left: 265px; top: 8px; width: 1000px; background-color: #F3F0F9;" /><n-avatar round size="medium" :src=user.avatarUrl style="position: absolute; right: 190px; top: 8px; "/><div style="position: absolute; right: 50px; top: 8px"><n-button round color="#7B3DE0" @click="showModalModal"><template #icon><n-icon><send /></n-icon></template>发布</n-button></div>     </div><div class="tabs"><n-card><rich-text-editor v-model:modelValue="addArticle.content"></rich-text-editor></n-card></div><n-modal v-model:show="showModal"><div style="width: 400px; height: 450px; background: white;"><n-card title="封面" :bordered="false" ><div v-if="!newHeadImage" style="width: 300px; height: 150px; margin: 0 auto;"><n-uploadmultipledirectory-dnd:max="1"@before-upload="beforeUpload":custom-request="customRequest"><n-upload-dragger><div style="margin-bottom: 12px"><n-icon size="48" :depth="3"><archive-icon /></n-icon></div><n-text style="font-size: 16px">点击或者拖动图片到此处</n-text></n-upload-dragger></n-upload></div><div v-else style="width: 230px; margin: 0 auto;"><n-image height="150" width="300" :src=serverUrl+addArticle.headImage />    <n-button @click="deleteImage" circle style="position: absolute; left: 298px; top: 50px;" color="#383838"><template #icon><n-icon><close /></n-icon></template></n-button>                </div></n-card><n-card title="分类" :bordered="false"><div style="width:300px; margin: 0 auto;"><n-select v-model:value="addArticle.categoryId" :options="categoryOptions" placeholder="请选择分类"/></div></n-card><div style="position: absolute; right: 100px; bottom: 30px;"><n-button type="default" @click="closeSubmitModal">取消</n-button></div><div style="position: absolute; right: 30px; bottom: 30px;"><n-button type="primary" @click="submit">确认</n-button></div>                </div></n-modal></template><script setup>
import {ref,reactive,inject, onMounted} from 'vue'
import RichTextEditor from '../components/RichTextEditor.vue'
import { ArchiveOutline as ArchiveIcon } from "@vicons/ionicons5"
import { Send } from "@vicons/ionicons5"
import { ReturnUpBack } from "@vicons/ionicons5"
import { Close } from "@vicons/ionicons5"import {useRouter, useRoute} from 'vue-router'
const router = useRouter()
const route = useRoute()const serverUrl = inject("serverUrl")
const axios = inject("axios")
const message = inject("message")const login = ref(false)
const user = reactive({avatarUrl: "",id: 0
})
const categoryOptions = ref([])
const addArticle = reactive({id: 0,categoryId: 0,title:"",content:"",headImage:"",
})
const showModal = ref(false)
const newHeadImage = ref(false)onMounted(() => {loadAvatar()loadCategories()
})const loadAvatar= async() => {let res = await axios.get("/user")    console.log(res)if (res.data.code == 200) {user.avatarUrl = serverUrl + res.data.data.avataruser.id = res.data.data.idlogin.value = true}
} const loadCategories = async() =>{let res = await axios.get("/category")console.log(res)categoryOptions.value = res.data.data.categories.map((item)=>{return {label:item.name,value:item.id}})
}const showModalModal = () => {showModal.value = true
}const closeSubmitModal = () => {showModal.value = false
}const beforeUpload = async(data) => {if (data.file.file?.type !== "image/png") {message.error("只能上传png格式的图片")return false;}return true;
}const customRequest = async({file}) => {const formData = new FormData()formData.append('file', file.file)let res = await axios.post("/upload", formData)console.log(res)addArticle.headImage = res.data.data.filePathnewHeadImage.value = true
}const deleteImage = () => {addArticle.headImage = ""newHeadImage.value = false
}const submit = async() => {let res = await axios.post("/article", {category_id: addArticle.categoryId,title: addArticle.title,content: addArticle.content,head_image: addArticle.headImage})console.log(res)   if (res.data.code == 200) {message.success(res.data.msg) goback()  } else {message.error(res.data.msg)}
}const goback= () => {router.go(-1)
}</script><style lang="scss" scoped>
.topbar {position: sticky;top: 0;height: 50px;background: white;box-shadow: 0px 1px 5px #D3D4D8;
}
.tabs {position: absolute;top: 75px;left: 0;right: 0;margin: auto;width: 1000px;height: auto;background: white;  box-shadow: 0px 1px 3px #D3D4D8; border-radius: 5px;
}
</style>

Updata.vue

<template><div class="topbar"><n-button @click="goback" strong quaternary round style="position: absolute; left: 50px; top: 7px; font-size: 24px;" color="#7B3DE0"><n-icon><return-up-back /></n-icon></n-button><text style="position:absolute; left: 200px; line-height: 50px; color: #383838">标题</text><n-input v-model:value="updateArticle.title" round placeholder="请输入标题" style="position:absolute; left: 265px; top: 8px; width: 1000px; background-color: #F3F0F9;" /><n-avatar round size="medium" :src=user.avatarUrl style="position: absolute; right: 190px; top: 8px; "/><div style="position: absolute; right: 50px; top: 8px"><n-button round color="#7B3DE0" @click="showModalModal"><template #icon><n-icon><send /></n-icon></template>发布</n-button></div>     </div><div class="tabs"><n-card><rich-text-editor v-if="loadOk" v-model:modelValue="updateArticle.content"></rich-text-editor></n-card></div><n-modal v-model:show="showModal"><div style="width: 400px; height: 450px; background: white;"><n-card title="封面" :bordered="false" ><div v-if="!newHeadImage" style="width: 300px; height: 150px; margin: 0 auto;"><n-uploadmultipledirectory-dnd:max="1"@before-upload="beforeUpload":custom-request="customRequest"><n-upload-dragger><div style="margin-bottom: 12px"><n-icon size="48" :depth="3"><archive-icon /></n-icon></div><n-text style="font-size: 16px">点击或者拖动图片到此处</n-text></n-upload-dragger></n-upload></div><div v-else style="width: 230px; margin: 0 auto;"><n-image height="150" width="300" :src=serverUrl+updateArticle.headImage />    <n-button @click="deleteImage" circle style="position: absolute; left: 298px; top: 50px;" color="#383838"><template #icon><n-icon><close /></n-icon></template></n-button>                </div></n-card><n-card title="分类" :bordered="false"><div style="width:300px; margin: 0 auto;"><n-select v-model:value="updateArticle.categoryId" :options="categoryOptions" placeholder="请选择分类"/></div></n-card><div style="position: absolute; right: 100px; bottom: 30px;"><n-button type="default" @click="closeSubmitModal">取消</n-button></div><div style="position: absolute; right: 30px; bottom: 30px;"><n-button type="primary" @click="submit">确认</n-button></div>                </div></n-modal></template><script setup>
import { ArchiveOutline as ArchiveIcon } from "@vicons/ionicons5"
import { Send } from "@vicons/ionicons5"
import { ReturnUpBack } from "@vicons/ionicons5"
import { Close } from "@vicons/ionicons5"
import {ref,reactive,inject, onMounted} from 'vue'
import RichTextEditor from '../components/RichTextEditor.vue'import {useRouter, useRoute} from 'vue-router'
const router = useRouter()
const route = useRoute()const serverUrl = inject("serverUrl")
const axios = inject("axios")
const message = inject("message")const loadOk = ref(false)
const user = reactive({avatarUrl: "",id: 0
})
const categoryOptions = ref([])
const updateArticle = reactive({id: 0,categoryId: 0,title:"",content:"",headImage:"",
})
const showModal = ref(false)
const newHeadImage = ref(false)onMounted(() => {loadAvatar()loadCategories()loadArticle()
})const loadAvatar= async() => {let res = await axios.get("/user")    console.log(res)if (res.data.code == 200) {user.avatarUrl = serverUrl + res.data.data.avataruser.id = res.data.data.id}
} const loadCategories = async() =>{let res = await axios.get("/category")console.log(res)categoryOptions.value = res.data.data.categories.map((item)=>{return {label:item.name,value:item.id}})
}const loadArticle = async() => {let res = await axios.get("/article/" + route.query.id)console.log(res)if (res.data.code == 200) {updateArticle.categoryId = res.data.data.article.categoryId,updateArticle.title = res.data.data.article.title,updateArticle.content = res.data.data.article.content,updateArticle.headImage = res.data.data.article.headImage,newHeadImage.value = updateArticle.headImage? true: false    loadOk.value = true   }
}const showModalModal = () => {showModal.value = true
}const closeSubmitModal = () => {showModal.value = false
}const beforeUpload = async(data) => {if (data.file.file?.type !== "image/png") {message.error("只能上传png格式的图片")return false;}return true;
}const customRequest = async({file}) => {const formData = new FormData()formData.append('file', file.file)let res = await axios.post("/upload", formData)console.log(res)updateArticle.headImage = res.data.data.filePathnewHeadImage.value = true
}const deleteImage = () => {updateArticle.headImage = ""newHeadImage.value = false
}const submit = async() => {let res = await axios.put("/article/" + route.query.id, {category_id: updateArticle.categoryId,title: updateArticle.title,content: updateArticle.content,head_image: updateArticle.headImage})console.log(res)   if (res.data.code == 200) {message.success(res.data.msg) goback()       } else {message.error(res.data.msg)}
}const goback= () => {router.go(-2)
}</script><style lang="scss" scoped>
.topbar {position: sticky;top: 0;height: 50px;background: white;box-shadow: 0px 1px 5px #D3D4D8;
}
.tabs {position: absolute;top: 75px;left: 0;right: 0;margin: auto;width: 1000px;height: auto;background: white;  box-shadow: 0px 1px 3px #D3D4D8; border-radius: 5px;
}
</style>

(八)文章详情页

在文章详情页中展示文章标题、内容、分类、作者头像等内容,这里需要判断查看文章详情的是否是作者,如果是的话添加编辑和删除按键

<template><div style="position: fixed; top: 0; height: 50px; width: 100%; z-index: 999; "><TopBar></TopBar></div><div class="tabs"><n-card><n-h1>{{articleInfo.title}}</n-h1><div style="height: 75px; background-color: #FCFAF7;"><n-avatar @click="toOtherUser" round size="medium" :src=userUrl style="position: relative; left: 20px; top: 20px; cursor: pointer;"/><text style="position: relative; left: 36px; color: #808080;">发布时间:{{articleInfo.createdAt}} </text><div style="position: relative; left: 70px; color: #808080;">文章分类:<n-tag type="warning">{{categoryName}}</n-tag></div><n-button v-if="self" @click="toUpdate" ghost style="bottom: 45px; left: 805px;" color="#7B3DE0">修改</n-button><n-button v-if="self" @click="toDelete" ghost style="bottom: 45px; left: 815px;" color="#7B3DE0">删除</n-button></div><n-divider /><n-button v-if=!collected text @click="newCollect" style="position: absolute; right: 40px; top: 25px; cursor: pointer;" round ghost color="#FFA876"><template #icon><n-icon><star-outline /></n-icon></template>收藏</n-button><n-button v-else text @click="unCollect" style="position: absolute; right: 40px; top: 25px; cursor: pointer;" round ghost color="#FFA876"><template #icon><n-icon><star /></n-icon></template>已收藏</n-button><div class="article-content"><div v-html="articleInfo.content"></div></div></n-card></div>
</template><script setup>
import {ref,reactive,inject, onMounted} from 'vue'
import TopBar from '../components/TopBar.vue'
import { Star } from "@vicons/ionicons5"
import { StarOutline } from "@vicons/ionicons5"import {useRouter, useRoute} from 'vue-router'
const router = useRouter()
const route = useRoute()const serverUrl = inject("serverUrl")
const axios = inject("axios")
const message = inject("message")
const dialog = inject("dialog")const articleInfo = ref({})
const categoryName = ref("")
const user = ref({})
const userUrl = ref("")
const collected = ref(false)
const index = ref(0)
const self = ref(false)onMounted(() => {loadArticle()
})const loadArticle = async() => {let res1 = await axios.get("article/" + route.query.id)console.log(res1)if (res1.data.code == 200) {articleInfo.value = res1.data.data.article let res2 = await axios.get("category/" + res1.data.data.article.category_id) console.log(res2)if (res2.data.code == 200) {categoryName.value = res2.data.data.categoryName}let res3 = await axios.get("user/briefInfo/" + res1.data.data.article.user_id)console.log(res3)if (res3.data.code == 200) {user.value = res3.data.datauserUrl.value = serverUrl + user.value.avatarif (user.value.id == user.value.loginId) {self.value = true}}    let res4 = await axios.get("collects/" + route.query.id) console.log(res4)if (res4.data.code == 200) {collected.value = res4.data.data.collectedindex.value = res4.data.data.index}  }
}const newCollect = async() => {let res = await axios.put("collects/new/" + route.query.id)console.log(res)  if (res.data.code == 200) {message.warning("已收藏", {showIcon: false})  loadArticle()  }
}const unCollect = async() => {let res = await axios.delete("collects/" + index.value)console.log(res)  if (res.data.code == 200) {message.warning("取消收藏", {showIcon: false})  loadArticle()  }
}const toOtherUser = () => {if (user.value.id == user.value.loginId) {router.push({path: "/myself",query: {id: user.value.id}})   } else {router.push({path: "/others",query: {id: user.value.id}})}
}const toUpdate = () => {router.push({path: "/update",query: {id: articleInfo.value.id}})
}const toDelete = async (blog) => {dialog.warning({title: '警告',content: '是否要删除',positiveText: '确定',negativeText: '取消',onPositiveClick: async () => {let res = await axios.delete("article/" + articleInfo.value.id)if(res.data.code == 200){message.info(res.data.msg)goback()}else{message.error(res.data.msg)}  },onNegativeClick: () => {}})
}const goback= () => {router.go(-1)
}</script><style lang="scss" scoped>
.tabs {position: absolute;top: 75px;left: 0;right: 0;margin: auto;width: 1000px;height: auto;background: white;  box-shadow: 0px 1px 3px #D3D4D8; border-radius: 5px;
}
.article-content img{max-width: 100% !important;
}
</style>

(九)总结

恭喜你已完成整个项目的搭建,完结撒花~~

全栈开发实战(二)——简易博客社区前端搭建教程(附源码)相关推荐

  1. 全栈开发实战(一)——简易博客社区后端搭建教程(附源码)

    全栈开发实战(一)--简易博客社区后端搭建 项目展示视频 项目Github地址 (一)项目准备 在项目开始前,首先确保你已安装好Go语言并配置好Go语言编辑器,同时安装好MySQL或其他数据库,其次, ...

  2. CSDN博客文章阅读模式插件(附源码)

    插件地址:https://greasyfork.org/zh-CN/scripts/380667-csdn%E5%8D%9A%E5%AE%A2%E9%98%85%E8%AF%BB%E6%A8%A1%E ...

  3. Visual C++实现黑白棋游戏项目实战二:界面的设计与实现(附源码和资源 超详细)

    需要源码和资源请点赞关注收藏后评论区留言私信~~~ 黑白棋游戏的Visual C++工程采用MFC对话框模式进行开发,下面对它进行详细介绍 一.游戏菜单的实现 首先要在工程资源中添加一个菜单资源类,菜 ...

  4. Android App开发实战项目之大头贴App功能实现(附源码和演示 简单易上手)

    需要图片集和源码请点赞关注收藏后评论区留言~~~ 一.需求描述 大头贴App有两个特征,第一个是头要大,拿来一张照片后把人像区域裁剪出来,这样新图片里的人头才会比较大,第二个是在周围贴上装饰物,而且装 ...

  5. Spring Boot 专栏全栈开发实战

    2020 年 11 月 12 日,Spring 官方发布了 Spring Boot 2.4.0 GA 的公告,链接为 Spring Boot 2.4.0 available now.为了让大家能够学习 ...

  6. 【哈士奇赠书活动 - 18期】-〖Flask Web全栈开发实战〗

    文章目录 ⭐️ 赠书活动 - <Flask Web全栈开发实战> ⭐️ 编辑推荐 ⭐️ 内容提要 ⭐️ 赠书活动 → 获奖名单 ⭐️ 赠书活动 - <Flask Web全栈开发实战& ...

  7. 【Python开发】Flask开发实战:个人博客(三)

    Flask开发实战:个人博客(三) 在[Python开发]Flask开发实战:个人博客(一) 中,我们已经完成了 数据库设计.数据准备.模板架构.表单设计.视图函数设计.电子邮件支持 等总体设计的内容 ...

  8. Spring Boot+Vue全栈开发实战——花了一个礼拜读懂了这本书

    很幸运能够阅读王松老师的<Spring Boot+Vue全栈开发实战>这本书!之前也看过Spring Boot与Vue的相关知识,自己也会使用了Spring Boot+Vue进行开发项目. ...

  9. ChatGPT全栈开发实战:从需求分析到数据可视化,一站式指南助你快速构建全面应用

    <ChatGPT全栈开发实战:从需求分析到数据可视化,一站式指南助你快速构建全面应用>是一本旨在引领我们进入全栈开发世界的综合指南.通过结合强大的ChatGPT技术和全栈开发的实践,我们将 ...

  10. 读书笔记《Spring Boot+Vue全栈开发实战》(下)

    本书将带你全面了解Spring Boot基础与实践,带领读者一步步进入 Spring Boot 的世界. 前言 第九章 Spring Boot缓存 第十章 Spring Boot安全管理 第十一章 S ...

最新文章

  1. 求二叉树第K层的节点个数+求二叉树叶子节点的个数
  2. [JS] - onmusewheel事件(兼容IE,FF,opera,safari,chrome)
  3. 转:Ubuntu下ibus-sunpinyin的安装及翻页快捷键设置!
  4. LAMP 系统性能调优,第 3 部分: MySQL 服务器调优(转)
  5. php class setter,设置器 - Setters《 PHP 面向对象 》
  6. RSYNC及其算法简单介绍
  7. leetcode python3 简单题101. Symmetric Tree
  8. 卸载软件 Geek Uninstaller
  9. 用keil怎么擦除_环氧树脂结构胶怎么清洗 结构胶弄到衣服上怎么洗掉
  10. 计算机不断自动重启,电脑不断自动重启怎么办?
  11. [PTA] 7-11 计算平均分
  12. 论坛社区小程序(前段+后端)安装教程
  13. 我在哪?要到哪里去?怎么去?
  14. Laplacian of Gaussian公式的英文推导过程
  15. 测试体重的手机软件,手机能测重量的软件
  16. Django连接mysql数据库操作
  17. 【华为机试真题 Python】素数之积
  18. SpringBoot集成EMail
  19. 读取excel中数据时,数字格式发生改变
  20. 51单片机能否实现硬件仿真

热门文章

  1. 移动APP开发框架盘点
  2. T6 根据书籍条形码ISBN查询书籍,完整的方案,可安装
  3. 阿里巴巴2020春招暑期实习笔试题
  4. ip 域名 端口了解
  5. 计算机的ps快捷键,PHOTOSHOP常用快捷键大全
  6. 教育培训招生小程序源码
  7. 工程课系列-Level3-Web应用课
  8. *(uint32_t *)(PERIPH) == GPIOX)
  9. 蔽月山房---作者,王阳明
  10. openg和VS2010的环境配置