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

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

项目展示视频
项目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请求
piniaVue的存储库,它允许您跨组件、页面共享状态
sassCSS的开发工具,提供许多便利写法
vue-routerVue.js官方的路由插件
naive-uiVue3的组件库
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" // 引入UserStore

axios.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()); // 引入pinia

app.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"; // 引入pinia

export 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.token
        if (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.avatar
        user.id = res.data.data.id
        login.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" // 引入UserStore

axios.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()); // 引入pinia

const 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-upload
                        multiple
                        directory-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.self
        user.avatarUrl = serverUrl + res.data.data.avatar
        user.name = res.data.data.name
        user.number = res.data.data.articles.length
        user.fans = res.data.data.fans
        user.id = res.data.data.id
        articles.value = res.data.data.articles
        collects.value = res.data.data.collects
        following.value = res.data.data.following
        newName.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.filePath
    newAvatar.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.self
        user.avatarUrl = serverUrl + res1.data.data.avatar
        user.name = res1.data.data.name
        user.number = res1.data.data.articles.length
        user.fans = res1.data.data.fans
        user.id = res1.data.data.id
        user.loginId = res1.data.data.loginId
        articles.value = res1.data.data.articles
        collects.value = res1.data.data.collects
        following.value = res1.data.data.following
        let res2 = await axios.get("following/" + route.query.id) 
        console.log(res2)
        if (res2.data.code == 200) {
            followed.value = res2.data.data.followed
            index.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 = categoryId
    pageInfo.pageNum = 1
    loadArticles()
}

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 格式(而不上传),默认为 0
    base64LimitSize: 10 * 1024, // 10kb
    server: 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.modelValue
      initFinished = true      
    }, 10)
})
let initFinished = false
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
    const editor = editorRef.value
    if (editor == null) return
    editor.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-upload
                    multiple
                    directory-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.avatar
        user.id = res.data.data.id
        login.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.filePath
    newHeadImage.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-upload
                    multiple
                    directory-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.avatar
        user.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.filePath
    newHeadImage.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.data
            userUrl.value = serverUrl + user.value.avatar
            if (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.collected
            index.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>

在这里插入图片描述

在这里插入图片描述

(九)总结

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

  • 11
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值