2025-01-05 11:41:11 +08:00
# web
Vblog 前端项目
## 清理模版代码
- 清理组件
- 清理样式
- 清理js文件
2025-01-05 15:09:34 +08:00
## 添加登录页面
- 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)
```
2025-01-05 16:09:35 +08:00
## 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
2025-01-05 17:15:05 +08:00
- 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)
},
)
```
2025-01-05 18:14:52 +08:00
## 路由嵌套与布局
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,
},
],
},
]
```
2025-01-12 12:07:48 +08:00
```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;
}
```
2025-01-12 17:22:23 +08:00
## 列表页
- 页头/过滤条件/表格展示
后端分页, 默认的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()
}
```
2025-01-12 18:31:26 +08:00
## 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 >
```
2025-01-19 10:06:22 +08:00
## 文章删除提醒
```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 >
```
2025-01-19 14:51:14 +08:00
## 文章的创建和更新
2025-01-19 10:06:22 +08:00
2025-01-19 11:29:35 +08:00
选择一个mardown的编辑器: https://www.npmjs.com/package/md-editor-v3
添加唯一建
```sql
UNIQUE KEY `idx_index` (`create_by` ,`category` ,`title` ) USING BTREE,
```
创建成功后,进入到保存模式
2025-01-19 14:51:14 +08:00
```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
)
```
2025-01-19 18:16:29 +08:00
## 前台布局
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 >
```
可以在后台的编辑页面,给一个跳转按钮,跳转到详情页进行预览。