923 lines
20 KiB
Markdown
923 lines
20 KiB
Markdown
# web
|
||
|
||
Vblog 前端项目
|
||
|
||
## 清理模版代码
|
||
|
||
- 清理组件
|
||
- 清理样式
|
||
- 清理js文件
|
||
|
||
## 添加登录页面
|
||
|
||
- LoginPage.vue
|
||
|
||
```vue
|
||
<template>
|
||
<div>登录页面</div>
|
||
</template>
|
||
|
||
<script setup></script>
|
||
|
||
<style lang="css" scoped></style>
|
||
```
|
||
|
||
- 添加路有
|
||
|
||
```js
|
||
const router = createRouter({
|
||
history: createWebHistory(import.meta.env.BASE_URL),
|
||
routes: [
|
||
{
|
||
path: '/login',
|
||
name: 'login',
|
||
component: () => import('../views/common/LoginPage.vue'),
|
||
},
|
||
],
|
||
})
|
||
```
|
||
|
||
- App组件,添加路由视图入口
|
||
|
||
```vue
|
||
<script setup></script>
|
||
|
||
<template>
|
||
<RouterView></RouterView>
|
||
</template>
|
||
|
||
<style scoped></style>
|
||
```
|
||
|
||
## 安装UI插件
|
||
|
||
https://gitee.com/infraboard/go-course/blob/master/day20/vue3-ui.md
|
||
|
||
```js
|
||
// 加载UI组件
|
||
import ArcoVue from '@arco-design/web-vue'
|
||
import '@arco-design/web-vue/dist/arco.css'
|
||
// 额外引入图标库
|
||
import ArcoVueIcon from '@arco-design/web-vue/es/icon'
|
||
app.use(ArcoVue)
|
||
app.use(ArcoVueIcon)
|
||
```
|
||
|
||
## Login Page开发
|
||
|
||
```vue
|
||
<template>
|
||
<div class="content">
|
||
<div class="login-container">
|
||
<div class="login-title">欢迎登录博客系统</div>
|
||
<div class="login-form">
|
||
<a-form size="large" :model="loginForm" @submit="handleSubmit">
|
||
<a-form-item
|
||
field="username"
|
||
hide-label
|
||
hide-asterisk
|
||
:rules="{ required: true, message: '请输入用户名' }"
|
||
>
|
||
<a-input
|
||
style="background-color: #fff;"
|
||
v-model="loginForm.username"
|
||
placeholder="请输入用户名"
|
||
>
|
||
<template #prefix>
|
||
<icon-user />
|
||
</template>
|
||
</a-input>
|
||
</a-form-item>
|
||
<a-form-item
|
||
style="margin-top: 12px"
|
||
field="password"
|
||
hide-label
|
||
hide-asterisk
|
||
:rules="{ required: true, message: '请输入密码' }"
|
||
>
|
||
<a-input-password
|
||
style="background-color: #fff;"
|
||
v-model="loginForm.password"
|
||
placeholder="请输入密码"
|
||
allow-clear
|
||
>
|
||
<template #prefix>
|
||
<icon-lock />
|
||
</template>
|
||
</a-input-password>
|
||
</a-form-item>
|
||
<a-form-item field="remember_me" hide-label hide-asterisk>
|
||
<a-checkbox style="margin-left: auto;" v-model="loginForm.remember_me" value="boolean"
|
||
>记住</a-checkbox
|
||
>
|
||
</a-form-item>
|
||
<a-form-item hide-label hide-asterisk>
|
||
<a-button type="primary" style="width: 100%;" html-type="submit">登录</a-button>
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { reactive } from 'vue'
|
||
|
||
const loginForm = reactive({
|
||
username: '',
|
||
password: '',
|
||
remember_me: false,
|
||
})
|
||
const handleSubmit = (data) => {
|
||
if (data.errors === undefined) {
|
||
console.log(data.values)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="css" scoped>
|
||
.content {
|
||
height: 100%;
|
||
width: 100%;
|
||
justify-content: center;
|
||
align-items: center;
|
||
display: flex;
|
||
background-color: rgb(243, 238, 238);
|
||
}
|
||
|
||
.login-container {
|
||
width: 460px;
|
||
height: 320px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
background: rgba(255, 255, 255, 0.3);
|
||
/* 增加透明度 */
|
||
border-radius: 15px;
|
||
backdrop-filter: blur(15px);
|
||
/* 增加模糊程度 */
|
||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.login-title {
|
||
margin-top: 12px;
|
||
margin-bottom: 12px;
|
||
font-size: 20px;
|
||
font-weight: 500;
|
||
color: var(--color-neutral-8);
|
||
}
|
||
|
||
.login-form {
|
||
padding: 20px;
|
||
height: 100%;
|
||
width: 100%;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
## 对接后端
|
||
|
||
调用后端的Restful接口, 需要一个http客户端
|
||
|
||
- XMLHTTPRequest
|
||
- fetch
|
||
- axios: https://www.axios-http.cn/docs/intro
|
||
|
||
```shell
|
||
npm install axios
|
||
```
|
||
|
||
### 后端API
|
||
|
||
```js
|
||
export const client = axios.create({
|
||
baseURL: 'http://127.0.0.1:8080',
|
||
timeout: 3000,
|
||
})
|
||
```
|
||
|
||
```js
|
||
import { client } from './client'
|
||
|
||
export const LOGIN = (data) =>
|
||
client({
|
||
url: '/api/vblog/v1/tokens',
|
||
method: 'POST',
|
||
data: data,
|
||
})
|
||
```
|
||
|
||
### 页面使用
|
||
|
||
```js
|
||
const summitLoading = ref(false)
|
||
const handleSubmit = async (data) => {
|
||
if (data.errors === undefined) {
|
||
try {
|
||
summitLoading.value = true
|
||
await LOGIN(data.values)
|
||
} catch (error) {
|
||
console.log(error)
|
||
} finally {
|
||
summitLoading.value = false
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```sh
|
||
Access to XMLHttpRequest at 'http://127.0.0.1:8080/api/vblog/v1/tokens' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
|
||
```
|
||
|
||
### 如何处理CORS
|
||
|
||
- 1. 服务端 允许跨越访问, 服务端需要配置一个CROS 中间件,返回 允许的 origin列表, 允许 origin localhost 访问
|
||
- 2. 前端使用代码, 前端先访问 -- proxy -- backend
|
||
|
||
推荐: 前端使用代理 , 最终的效果是 前端和后端部署在一起
|
||
|
||
前端如何配置代理: (vite)
|
||
|
||
```js
|
||
// https://vite.dev/config/
|
||
export default defineConfig({
|
||
plugins: [vue(), vueJsx(), vueDevTools()],
|
||
resolve: {
|
||
alias: {
|
||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||
},
|
||
},
|
||
server: {
|
||
proxy: {
|
||
'/api': 'http://localhost:8080',
|
||
},
|
||
},
|
||
})
|
||
```
|
||
|
||
http client在请求的时候,不能指定baseURL: http://127.0.0.1:8080/api/vblog/v1/tokens,
|
||
|
||
```
|
||
http://localhost:5173/api/vblog/v1/tokens --> http://localhost:8080/vblog/v1/tokens
|
||
```
|
||
|
||
移除baseURL配置
|
||
|
||
```js
|
||
export const client = axios.create({
|
||
baseURL: '',
|
||
timeout: 3000,
|
||
})
|
||
```
|
||
|
||
### API 访问报错处理
|
||
|
||
添加一个响应拦截器来进行响应的处理
|
||
|
||
```js
|
||
// http 客户端配置一个拦截器
|
||
client.interceptors.response.use(
|
||
// 请求成功的拦截器
|
||
(value) => {
|
||
return value
|
||
},
|
||
// 请求失败
|
||
(error) => {
|
||
let msg = error.message
|
||
try {
|
||
msg = error.response.data.message
|
||
} catch (error) {
|
||
console.log(error)
|
||
}
|
||
Message.error(msg)
|
||
},
|
||
)
|
||
```
|
||
|
||
## 路由嵌套与布局
|
||
|
||
https://router.vuejs.org/zh/guide/essentials/nested-routes.html
|
||
|
||
```js
|
||
const routes = [
|
||
{
|
||
path: '/user/:id',
|
||
component: User,
|
||
children: [
|
||
{
|
||
// 当 /user/:id/profile 匹配成功
|
||
// UserProfile 将被渲染到 User 的 <router-view> 内部
|
||
path: 'profile',
|
||
component: UserProfile,
|
||
},
|
||
{
|
||
// 当 /user/:id/posts 匹配成功
|
||
// UserPosts 将被渲染到 User 的 <router-view> 内部
|
||
path: 'posts',
|
||
component: UserPosts,
|
||
},
|
||
],
|
||
},
|
||
]
|
||
```
|
||
|
||
```js
|
||
// <a-menu-item key="backend_blog_list">文章管理</a-menu-item>
|
||
// <a-menu-item key="backend_tag_list">标签管理</a-menu-item>
|
||
const handleMenuItemClick = (key) => {
|
||
// https://router.vuejs.org/zh/guide/essentials/navigation.html
|
||
router.push({ name: key })
|
||
}
|
||
```
|
||
|
||
## 登录与退出
|
||
|
||
```js
|
||
const summitLoading = ref(false)
|
||
const handleSubmit = async (data) => {
|
||
if (data.errors === undefined) {
|
||
try {
|
||
summitLoading.value = true
|
||
const tk = await LOGIN(data.values)
|
||
// 保存当前用户的登录状态
|
||
token.value.access_token = tk.access_token
|
||
token.value.ref_user_name = tk.ref_user_name
|
||
token.value.refresh_token = tk.refresh_token
|
||
|
||
// 需要把用户重定向到Home页面去了
|
||
router.push({ name: 'blog_management' })
|
||
} catch (error) {
|
||
console.log(error)
|
||
} finally {
|
||
summitLoading.value = false
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
```js
|
||
const logout = () => {
|
||
// 需要调用Logout接口的, 自己添加下退出接口
|
||
// 之前的登录状态给清理掉, store
|
||
token.value = undefined
|
||
// 需要重定向到登录页面
|
||
router.push({ name: 'login' })
|
||
}
|
||
```
|
||
|
||
## 导航守卫
|
||
|
||
https://router.vuejs.org/zh/guide/advanced/navigation-guards.html
|
||
|
||
```js
|
||
const router = createRouter({ ... })
|
||
|
||
router.beforeEach((to, from) => {
|
||
// ...
|
||
// 返回 false 以取消导航
|
||
return false
|
||
})
|
||
```
|
||
|
||
```js
|
||
router.beforeEach((to, from) => {
|
||
const whiteList = ['login']
|
||
if (!whiteList.includes(to.name)) {
|
||
// 需要做权限判断
|
||
if (isLogin()) {
|
||
return true
|
||
}
|
||
// 返回跳转后的页面
|
||
return { name: 'login' }
|
||
}
|
||
})
|
||
```
|
||
|
||
## 覆盖UI的默认样式
|
||
|
||
css, :deep(), 用于选择需要覆盖的class进行覆盖
|
||
|
||
:deep 选择器并不是一个通用的 CSS 选择器,而是特定于某些框架(如 Vue.js 和 Svelte)中用于 Scoped CSS 的一个特性。它的主要目的是允许开发者在使用 Scoped CSS 的情况下,能够针对子组件或外部组件的样式进行覆盖。
|
||
|
||
```js
|
||
<style lang="css" scoped>
|
||
:deep(.page-header .arco-page-header-wrapper) {
|
||
padding-left: 0px;
|
||
}
|
||
```
|
||
|
||
## 列表页
|
||
|
||
- 页头/过滤条件/表格展示
|
||
|
||
后端分页, 默认的Table使用的前端分页
|
||
|
||
```html
|
||
<div class="pagination">
|
||
<a-pagination
|
||
:current="blogQueryRequest.page_number"
|
||
:page-size="blogQueryRequest.page_size"
|
||
:page-size-options="[2, 10, 20, 30, 50]"
|
||
@change="pageNumberChanged"
|
||
@page-size-change="pageSizeChanged"
|
||
:total="data.total"
|
||
show-total
|
||
show-jumper
|
||
show-page-size
|
||
/>
|
||
</div>
|
||
```
|
||
|
||
```js
|
||
// 实现分页
|
||
const pageNumberChanged = (current) => {
|
||
blogQueryRequest.page_number = current
|
||
// 基于新的变化重新请求数据
|
||
queryData()
|
||
}
|
||
const pageSizeChanged = (pageSize) => {
|
||
blogQueryRequest.page_size = pageSize
|
||
blogQueryRequest.page_number = 1
|
||
// 基于新的变化重新请求数据
|
||
queryData()
|
||
}
|
||
```
|
||
|
||
## Menu状态保存
|
||
|
||
```js
|
||
import { useStorage } from '@vueuse/core'
|
||
|
||
export const systemConfig = useStorage(
|
||
'system_config',
|
||
{
|
||
selected_current_menu_item_key: '',
|
||
},
|
||
localStorage,
|
||
{
|
||
mergeDefaults: true,
|
||
},
|
||
)
|
||
```
|
||
|
||
点击时,保存这个状态
|
||
|
||
```js
|
||
const handleMenuItemClick = (key) => {
|
||
// https://router.vuejs.org/zh/guide/essentials/navigation.html
|
||
router.push({ name: key })
|
||
systemConfig.value.selected_current_menu_item_key = key
|
||
}
|
||
```
|
||
|
||
```html
|
||
<a-menu
|
||
@menu-item-click="handleMenuItemClick"
|
||
breakpoint="xl"
|
||
:default-selected-keys="[systemConfig.selected_current_menu_item_key]"
|
||
auto-open
|
||
>
|
||
</a-menu>
|
||
```
|
||
|
||
## 文章删除
|
||
|
||
1. 准备好你的后端接口:
|
||
|
||
```go
|
||
// DeleteBlog implements blog.Service.
|
||
func (b *BlogServiceImpl) DeleteBlog(ctx context.Context, in *blog.DeleteBlogRequest) error {
|
||
ins, err := b.DescribeBlog(ctx, blog.NewDescribeBlogRequest(in.Id))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return datasource.DBFromCtx(ctx).Where("id = ?", ins.Id).Delete(ins).Error
|
||
}
|
||
|
||
// DescribeBlog implements blog.Service.
|
||
func (b *BlogServiceImpl) DescribeBlog(ctx context.Context, in *blog.DescribeBlogRequest) (*blog.Blog, error) {
|
||
ins := &blog.Blog{}
|
||
err := datasource.DBFromCtx(ctx).Where("id = ?", in.Id).First(ins).Error
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return ins, nil
|
||
}
|
||
```
|
||
|
||
```go
|
||
r.DELETE(":id", h.DeleteBlog)
|
||
|
||
func (h *BlogApiHandler) DeleteBlog(ctx *gin.Context) {
|
||
in := blog.NewDeleteBlogRequest(ctx.Param("id"))
|
||
|
||
err := h.blog.DeleteBlog(ctx.Request.Context(), in)
|
||
if err != nil {
|
||
response.Failed(ctx, err)
|
||
return
|
||
}
|
||
response.Success(ctx, "ok")
|
||
}
|
||
```
|
||
|
||
2. SetCookie的问题: localhost
|
||
|
||
```go
|
||
// 打印下日志, ioc
|
||
domain := application.Get().Domain()
|
||
log.L().Debug().Msgf("cookie domain: %s", domain)
|
||
|
||
// 设置Cookie
|
||
ctx.SetCookie(token.COOKIE_NAME, ins.AccessToken, ins.AccessTokenExpireTTL(), "/", domain, false, true)
|
||
```
|
||
|
||
```toml
|
||
[app]
|
||
name = "vblog"
|
||
description = "app desc"
|
||
address = "http://localhost/"
|
||
```
|
||
|
||
3. 调用API, 实现删除, 刷新页面(2个操作的先后关系)
|
||
|
||
```js
|
||
// 删除函数
|
||
const delteLoadding = ref(0)
|
||
const deleteBlog = async (id) => {
|
||
try {
|
||
delteLoadding.value = id
|
||
// 等待这个操作执行完成
|
||
await DELTE_BLOG(id)
|
||
// 刷新数据
|
||
queryData()
|
||
} finally {
|
||
delteLoadding.value = 0
|
||
}
|
||
}
|
||
```
|
||
|
||
4. 删除按钮Loadding控制:
|
||
|
||
```html
|
||
<a-button :loading="delteLoadding === record.id" status="danger" @click="deleteBlog(record.id)">
|
||
<template #icon>
|
||
<icon-delete />
|
||
</template>
|
||
删除
|
||
</a-button>
|
||
```
|
||
|
||
## 文章删除提醒
|
||
|
||
```vue
|
||
<a-popconfirm
|
||
:okLoading="delteLoadding === record.id"
|
||
@ok="deleteBlog(record.id)"
|
||
type="warning"
|
||
:content="`是否需要删除【${record.title}】?`"
|
||
>
|
||
<a-button :loading="delteLoadding === record.id" status="danger">
|
||
<template #icon>
|
||
<icon-delete />
|
||
</template>
|
||
删除
|
||
</a-button>
|
||
</a-popconfirm>
|
||
```
|
||
|
||
## 文章的创建和更新
|
||
|
||
选择一个mardown的编辑器: https://www.npmjs.com/package/md-editor-v3
|
||
|
||
添加唯一建
|
||
|
||
```sql
|
||
UNIQUE KEY `idx_index` (`create_by`,`category`,`title`) USING BTREE,
|
||
```
|
||
|
||
创建成功后,进入到保存模式
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<!-- 页头 -->
|
||
<div class="page-header">
|
||
<a-page-header @back="$router.go(-1)" :title="isEdit ? '编辑文章' : '创建文章'">
|
||
<template #extra>
|
||
<a-button :loading="saveLoading" @click="save" type="primary">
|
||
<template #icon>
|
||
<icon-save />
|
||
</template>
|
||
保存
|
||
</a-button>
|
||
</template>
|
||
</a-page-header>
|
||
</div>
|
||
<!-- form表单 -->
|
||
<a-form ref="formRef" :model="form" layout="vertical">
|
||
<a-form-item
|
||
field="title"
|
||
:rules="{ required: true, message: '请输入文章的标题' }"
|
||
label="标题"
|
||
>
|
||
<a-input v-model="form.title" placeholder="请填写文章的标题" />
|
||
</a-form-item>
|
||
<a-form-item
|
||
:rules="{ required: true, message: '请选择文章分类' }"
|
||
field="category"
|
||
label="分类"
|
||
>
|
||
<a-select v-model="form.category" placeholder="请选择文章分类">
|
||
<a-option>软件开发</a-option>
|
||
<a-option>系统运维</a-option>
|
||
<a-option>软件测试</a-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item field="summary" label="概要">
|
||
<a-textarea
|
||
v-model="form.summary"
|
||
:auto-size="{ minRows: 4, maxRows: 4 }"
|
||
placeholder="请填写文章的概要信息"
|
||
allow-clear
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item field="content" label="文章内容">
|
||
<MdEditor class="editor" v-model="form.content" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { onMounted, reactive, ref, useTemplateRef } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { MdEditor } from 'md-editor-v3'
|
||
import 'md-editor-v3/lib/style.css'
|
||
import { CREATE_BLOG, DESCRIBE_BLOG, UPDATE_BLOG } from '@/api/blog'
|
||
|
||
const formInstance = useTemplateRef('formRef')
|
||
|
||
// 查询当前页面的路由信息
|
||
const route = useRoute()
|
||
const isEdit = ref(route.query.id ? true : false)
|
||
const router = useRouter()
|
||
|
||
onMounted(async () => {
|
||
if (isEdit.value) {
|
||
const resp = await DESCRIBE_BLOG(route.query.id)
|
||
Object.assign(form, { ...resp })
|
||
}
|
||
})
|
||
|
||
const form = reactive({
|
||
title: '',
|
||
summary: '',
|
||
content: '',
|
||
category: '',
|
||
tags: {},
|
||
})
|
||
|
||
// 文章的保存
|
||
const saveLoading = ref(false)
|
||
const save = () => {
|
||
if (isEdit.value) {
|
||
// 更新
|
||
formInstance.value.validate(async (errors) => {
|
||
if (errors === undefined) {
|
||
try {
|
||
saveLoading.value = true
|
||
await UPDATE_BLOG(route.query.id, form)
|
||
} finally {
|
||
saveLoading.value = false
|
||
}
|
||
}
|
||
})
|
||
} else {
|
||
// 数据提交给后端之前,我们是需要前端校验数据合法性
|
||
// formInstance.validate()
|
||
// formInstance
|
||
// https://cn.vuejs.org/guide/essentials/template-refs.html
|
||
formInstance.value.validate(async (errors) => {
|
||
if (errors === undefined) {
|
||
try {
|
||
saveLoading.value = true
|
||
const resp = await CREATE_BLOG(form)
|
||
router.replace({ name: 'backend_blog_edit', query: { id: resp.id } })
|
||
isEdit.value = true
|
||
} finally {
|
||
saveLoading.value = false
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="css" scoped>
|
||
.editor {
|
||
height: calc(100vh - 100px);
|
||
}
|
||
</style>
|
||
```
|
||
|
||
## 文章的发布
|
||
|
||
后端如何自定义状态展示
|
||
|
||
```go
|
||
type STAGE int
|
||
|
||
func (s STAGE) String() string {
|
||
return STAGE_MAPPING[s]
|
||
}
|
||
|
||
// 自定义 类型的序列化方式
|
||
// "草稿"
|
||
func (s STAGE) MarshalJSON() ([]byte, error) {
|
||
return []byte(`"` + s.String() + `"`), nil
|
||
}
|
||
|
||
// UnmarshalJSON([]byte) error
|
||
func (s *STAGE) UnmarshalJSON(data []byte) error {
|
||
str := strings.Trim(string(data), `"`)
|
||
switch str {
|
||
case "草稿":
|
||
*s = STAGE_DRAFT
|
||
case "已发布":
|
||
*s = STAGE_PUBLISHED
|
||
default:
|
||
return fmt.Errorf("不支持的发布类型")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
var STAGE_MAPPING = map[STAGE]string{
|
||
STAGE_DRAFT: "草稿",
|
||
STAGE_PUBLISHED: "已发布",
|
||
}
|
||
|
||
const (
|
||
STAGE_DRAFT STAGE = iota
|
||
STAGE_PUBLISHED
|
||
)
|
||
```
|
||
|
||
## 前台布局
|
||
|
||
1. 前台整体的布局, 组件的复用,Login/Logout
|
||
|
||
```vue
|
||
<template>
|
||
<a-space>
|
||
<a-button v-if="isLogin()" @click="logout">
|
||
<span style="margin-right: 12px;"> 退出 </span>
|
||
<icon-export />
|
||
</a-button>
|
||
<a-button v-else @click="$router.push({ name: 'login' })">
|
||
<span> 登录 </span>
|
||
</a-button>
|
||
</a-space>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { isLogin, token } from '@/stores/token'
|
||
import { useRouter } from 'vue-router'
|
||
|
||
const props = defineProps({
|
||
redirectToLogin: {
|
||
type: Boolean,
|
||
value: true,
|
||
},
|
||
})
|
||
|
||
const router = useRouter()
|
||
|
||
const logout = () => {
|
||
token.value = undefined
|
||
if (props.redirectToLogin) {
|
||
router.push({ name: 'login' })
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="css" scoped></style>
|
||
```
|
||
|
||
前台布局模版:
|
||
|
||
```vue
|
||
<template>
|
||
<a-layout class="layout">
|
||
<!-- 顶部导航 -->
|
||
<a-layout-header class="header">
|
||
<div style="font-size: 16px;font-weight: 500;">博客管理系统</div>
|
||
<div>
|
||
<LogoutButton :redirectToLogin="false"></LogoutButton>
|
||
</div>
|
||
</a-layout-header>
|
||
<a-layout-content class="content">
|
||
<div class="left"></div>
|
||
<div class="center">
|
||
<RouterView></RouterView>
|
||
</div>
|
||
<div class="right"></div>
|
||
</a-layout-content>
|
||
</a-layout>
|
||
</template>
|
||
|
||
<script setup>
|
||
import LogoutButton from '@/components/LogoutButton.vue'
|
||
</script>
|
||
```
|
||
|
||
## 前台的列表页
|
||
|
||
```vue
|
||
<template>
|
||
<a-list
|
||
:loading="queryBlogLoadding"
|
||
class="list-demo-action-layout"
|
||
:bordered="false"
|
||
:data="data.items"
|
||
:pagination-props="{ pageSize: blogQueryRequest.page_size, total: data.total }"
|
||
>
|
||
<template #item="{ item }">
|
||
<a-list-item class="list-demo-item" action-layout="vertical">
|
||
<template #actions>
|
||
<span><icon-heart />83</span>
|
||
<span><icon-star />{{ item.id }}</span>
|
||
<span><icon-message />Reply</span>
|
||
</template>
|
||
<template #extra>
|
||
<div className="image-area">
|
||
<img
|
||
@click="$router.push({ name: 'frontend_blog_detail', query: { id: item.id } })"
|
||
alt="arco-design"
|
||
width="200"
|
||
height="120"
|
||
src="https://p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/1f61854a849a076318ed527c8fca1bbf.png~tplv-uwbnlip3yd-webp.webp"
|
||
/>
|
||
</div>
|
||
</template>
|
||
<a-list-item-meta :title="item.title" :description="item.summary"> </a-list-item-meta>
|
||
</a-list-item>
|
||
</template>
|
||
</a-list>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { QUERY_BLOG } from '@/api/blog'
|
||
|
||
onMounted(() => {
|
||
queryData()
|
||
})
|
||
|
||
// API 请求数据
|
||
const blogQueryRequest = reactive({
|
||
keywords: '',
|
||
category: '',
|
||
page_size: 10,
|
||
page_number: 1,
|
||
// 只能查询出已发布,未发布,不能查询出来
|
||
})
|
||
// 数据, reactive, 一个一个修改每个属性的值 ({data...})
|
||
// ref data.value = {}
|
||
const data = ref({
|
||
items: [],
|
||
total: 0,
|
||
})
|
||
|
||
// 查询函数
|
||
const queryBlogLoadding = ref(false)
|
||
const queryData = async () => {
|
||
try {
|
||
queryBlogLoadding.value = true
|
||
data.value = await QUERY_BLOG(blogQueryRequest)
|
||
} finally {
|
||
queryBlogLoadding.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="css" scoped></style>
|
||
```
|
||
|
||
## 前台的详情页
|
||
|
||
自己补充上
|
||
|
||
```vue
|
||
<template>
|
||
<div>文章详情</div>
|
||
</template>
|
||
|
||
<script setup></script>
|
||
|
||
<style lang="css" scoped></style>
|
||
```
|
||
|
||
可以在后台的编辑页面,给一个跳转按钮,跳转到详情页进行预览。
|