go17/vblog/web/README.md
2025-01-19 18:16:29 +08:00

923 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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>
```
可以在后台的编辑页面,给一个跳转按钮,跳转到详情页进行预览。