..
2025-01-05 11:41:11 +08:00
2025-01-05 11:41:11 +08:00
2025-01-05 11:41:11 +08:00
2025-01-05 11:41:11 +08:00
2025-01-05 11:41:11 +08:00
2025-01-05 11:41:11 +08:00
2025-01-05 11:41:11 +08:00
2025-01-19 11:29:35 +08:00
2025-01-19 11:29:35 +08:00

web

Vblog 前端项目

清理模版代码

  • 清理组件
  • 清理样式
  • 清理js文件

添加登录页面

  • LoginPage.vue
<template>
  <div>登录页面</div>
</template>

<script setup></script>

<style lang="css" scoped></style>
  • 添加路有
const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/login',
      name: 'login',
      component: () => import('../views/common/LoginPage.vue'),
    },
  ],
})
  • App组件添加路由视图入口
<script setup></script>

<template>
  <RouterView></RouterView>
</template>

<style scoped></style>

安装UI插件

https://gitee.com/infraboard/go-course/blob/master/day20/vue3-ui.md

// 加载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开发

<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客户端

npm install axios

后端API

export const client = axios.create({
  baseURL: 'http://127.0.0.1:8080',
  timeout: 3000,
})
import { client } from './client'

export const LOGIN = (data) =>
  client({
    url: '/api/vblog/v1/tokens',
    method: 'POST',
    data: data,
  })

页面使用

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
    }
  }
}
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 访问
    1. 前端使用代码, 前端先访问 -- proxy -- backend

推荐: 前端使用代理 , 最终的效果是 前端和后端部署在一起

前端如何配置代理: (vite)

// 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配置

export const client = axios.create({
  baseURL: '',
  timeout: 3000,
})

API 访问报错处理

添加一个响应拦截器来进行响应的处理

// 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

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,
      },
    ],
  },
]
// <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 })
}

登录与退出

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
    }
  }
}
const logout = () => {
  // 需要调用Logout接口的, 自己添加下退出接口
  // 之前的登录状态给清理掉, store
  token.value = undefined
  // 需要重定向到登录页面
  router.push({ name: 'login' })
}

导航守卫

https://router.vuejs.org/zh/guide/advanced/navigation-guards.html

const router = createRouter({ ... })

router.beforeEach((to, from) => {
  // ...
  // 返回 false 以取消导航
  return false
})
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 的情况下,能够针对子组件或外部组件的样式进行覆盖。

<style lang="css" scoped>
:deep(.page-header .arco-page-header-wrapper) {
  padding-left: 0px;
}

列表页

  • 页头/过滤条件/表格展示

后端分页, 默认的Table使用的前端分页

<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>
// 实现分页
const pageNumberChanged = (current) => {
  blogQueryRequest.page_number = current
  // 基于新的变化重新请求数据
  queryData()
}
const pageSizeChanged = (pageSize) => {
  blogQueryRequest.page_size = pageSize
  blogQueryRequest.page_number = 1
  // 基于新的变化重新请求数据
  queryData()
}

Menu状态保存

import { useStorage } from '@vueuse/core'

export const systemConfig = useStorage(
  'system_config',
  {
    selected_current_menu_item_key: '',
  },
  localStorage,
  {
    mergeDefaults: true,
  },
)

点击时,保存这个状态

const handleMenuItemClick = (key) => {
  // https://router.vuejs.org/zh/guide/essentials/navigation.html
  router.push({ name: key })
  systemConfig.value.selected_current_menu_item_key = key
}
<a-menu
  @menu-item-click="handleMenuItemClick"
  breakpoint="xl"
  :default-selected-keys="[systemConfig.selected_current_menu_item_key]"
  auto-open
>
</a-menu>

文章删除

  1. 准备好你的后端接口:
// 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
}
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")
}
  1. SetCookie的问题: localhost
	// 打印下日志, 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)
[app]
  name = "vblog"
  description = "app desc"
  address = "http://localhost/"
  1. 调用API, 实现删除, 刷新页面(2个操作的先后关系)
// 删除函数
const delteLoadding = ref(0)
const deleteBlog = async (id) => {
  try {
    delteLoadding.value = id
    // 等待这个操作执行完成
    await DELTE_BLOG(id)
    // 刷新数据
    queryData()
  } finally {
    delteLoadding.value = 0
  }
}
  1. 删除按钮Loadding控制:
<a-button :loading="delteLoadding === record.id" status="danger" @click="deleteBlog(record.id)">
  <template #icon>
    <icon-delete />
  </template>
  删除
</a-button>

文章删除提醒

<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

添加唯一建

  UNIQUE KEY `idx_index` (`create_by`,`category`,`title`) USING BTREE,

创建成功后,进入到保存模式

<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>

文章的发布

后端如何自定义状态展示

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
)