├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── deploy
├── .gitignore
├── README.md
├── bootstrap.sh
├── build
│ ├── mysql
│ │ ├── Dockerfile
│ │ ├── README.md
│ │ ├── gvb.sql
│ │ ├── mysql.cnf
│ │ └── run.sh
│ ├── server
│ │ └── README.md
│ └── web
│ │ ├── Dockerfile
│ │ ├── README.md
│ │ ├── default.conf.ssl.template
│ │ ├── default.conf.template
│ │ └── run.sh
├── build_web.sh
├── clean_docker.sh
└── start
│ ├── .env
│ ├── README.md
│ ├── docker-compose.yml
│ ├── server.crt
│ └── server.key
├── gin-blog-admin
├── .dockerignore
├── .editorconfig
├── .env
├── .env.development
├── .env.production
├── .gitignore
├── .npmrc
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── README.md
├── eslint.config.js
├── index.html
├── jsconfig.json
├── package.json
├── pnpm-lock.yaml
├── public
│ ├── favicon.svg
│ ├── image
│ │ ├── 404.webp
│ │ ├── login_banner.webp
│ │ ├── login_bg.webp
│ │ └── logo.svg
│ └── resource
│ │ ├── loading.css
│ │ ├── loading.js
│ │ └── logo.svg
├── src
│ ├── App.vue
│ ├── api.js
│ ├── assets
│ │ ├── config.js
│ │ ├── icons.js
│ │ └── themes.js
│ ├── components
│ │ ├── UploadOne.vue
│ │ ├── common
│ │ │ ├── AppPage.vue
│ │ │ ├── CommonPage.vue
│ │ │ ├── ScrollX.vue
│ │ │ └── TheFooter.vue
│ │ ├── crud
│ │ │ ├── CrudModal.vue
│ │ │ ├── CrudTable.vue
│ │ │ └── QueryItem.vue
│ │ └── icon
│ │ │ ├── IconPicker.vue
│ │ │ └── TheIcon.vue
│ ├── composables
│ │ ├── index.js
│ │ ├── useCRUD.js
│ │ └── useForm.js
│ ├── layout
│ │ ├── header
│ │ │ ├── components
│ │ │ │ ├── BreadCrumb.vue
│ │ │ │ ├── FullScreen.vue
│ │ │ │ ├── GithubSite.vue
│ │ │ │ ├── MenuCollapse.vue
│ │ │ │ ├── ThemeMode.vue
│ │ │ │ ├── UserAvatar.vue
│ │ │ │ └── Watermark.vue
│ │ │ └── index.vue
│ │ ├── index.vue
│ │ ├── sidebar
│ │ │ ├── components
│ │ │ │ ├── SideLogo.vue
│ │ │ │ └── SideMenu.vue
│ │ │ └── index.vue
│ │ └── tags
│ │ │ ├── ContextMenu.vue
│ │ │ └── index.vue
│ ├── main.js
│ ├── router
│ │ ├── guard.js
│ │ ├── index.js
│ │ └── routes.js
│ ├── store
│ │ ├── index.js
│ │ └── modules
│ │ │ ├── auth.js
│ │ │ ├── permission.js
│ │ │ ├── tag.js
│ │ │ ├── theme.js
│ │ │ └── user.js
│ ├── utils
│ │ ├── http.js
│ │ ├── index.js
│ │ ├── local.js
│ │ └── naiveTool.js
│ └── views
│ │ ├── Login.vue
│ │ ├── article
│ │ ├── category
│ │ │ └── index.vue
│ │ ├── list
│ │ │ └── index.vue
│ │ ├── route.js
│ │ ├── tag
│ │ │ └── index.vue
│ │ └── write
│ │ │ └── index.vue
│ │ ├── auth
│ │ ├── menu
│ │ │ └── index.vue
│ │ ├── resource
│ │ │ └── index.vue
│ │ ├── role
│ │ │ └── index.vue
│ │ └── route.js
│ │ ├── error-page
│ │ ├── 404.vue
│ │ └── route.js
│ │ ├── home
│ │ ├── index.vue
│ │ └── route.js
│ │ ├── log
│ │ ├── login
│ │ │ └── index.vue
│ │ ├── operation
│ │ │ └── index.vue
│ │ └── route.js
│ │ ├── message
│ │ ├── comment
│ │ │ └── index.vue
│ │ ├── leave-msg
│ │ │ └── index.vue
│ │ └── route.js
│ │ ├── profile
│ │ ├── index.vue
│ │ └── route.js
│ │ ├── setting
│ │ ├── about
│ │ │ └── index.vue
│ │ ├── link
│ │ │ └── index.vue
│ │ ├── page
│ │ │ └── index.vue
│ │ ├── route.js
│ │ └── website
│ │ │ └── index.vue
│ │ ├── test
│ │ └── index.vue
│ │ └── user
│ │ ├── list
│ │ └── index.vue
│ │ ├── online
│ │ └── index.vue
│ │ └── route.js
├── uno.config.js
└── vite.config.js
├── gin-blog-front
├── .dockerignore
├── .editorconfig
├── .env
├── .env.development
├── .env.production
├── .gitignore
├── .npmrc
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── README.md
├── eslint.config.js
├── index.html
├── jsconfig.json
├── package.json
├── pnpm-lock.yaml
├── public
│ ├── cursor
│ │ ├── handwriting.cur
│ │ ├── link.cur
│ │ └── normal.cur
│ ├── favicon.svg
│ ├── images
│ │ ├── 404.svg
│ │ └── empty_friend_link.svg
│ └── js
│ │ └── mathjax.js
├── src
│ ├── App.vue
│ ├── api.js
│ ├── assets
│ │ ├── config.js
│ │ └── emoji.js
│ ├── components
│ │ ├── BackTop.vue
│ │ ├── BannerPage.vue
│ │ ├── comment
│ │ │ ├── Comment.vue
│ │ │ ├── CommentField.vue
│ │ │ └── Paging.vue
│ │ ├── layout
│ │ │ ├── AppFooter.vue
│ │ │ ├── AppHeader.vue
│ │ │ └── MobileSideBar.vue
│ │ ├── modal
│ │ │ ├── LoginModal.vue
│ │ │ ├── RegisterModal.vue
│ │ │ ├── SearchModal.vue
│ │ │ └── index.vue
│ │ └── ui
│ │ │ ├── UButton.vue
│ │ │ ├── UDrawer.vue
│ │ │ ├── ULoading.vue
│ │ │ ├── UModal.vue
│ │ │ ├── USpin.vue
│ │ │ └── UToast.vue
│ ├── main.js
│ ├── router.js
│ ├── store
│ │ ├── app.js
│ │ ├── index.js
│ │ └── user.js
│ ├── styles
│ │ ├── animate.css
│ │ ├── common.css
│ │ ├── index.css
│ │ └── nprogress.css
│ ├── utils
│ │ ├── http.js
│ │ ├── index.js
│ │ └── local.js
│ └── views
│ │ ├── about
│ │ └── index.vue
│ │ ├── article
│ │ ├── detail
│ │ │ ├── components
│ │ │ │ ├── BannerInfo.vue
│ │ │ │ ├── Catalogue.vue
│ │ │ │ ├── Copyright.vue
│ │ │ │ ├── Forward.vue
│ │ │ │ ├── LastNext.vue
│ │ │ │ ├── LatestList.vue
│ │ │ │ ├── Recommend.vue
│ │ │ │ └── Reward.vue
│ │ │ └── index.vue
│ │ └── list
│ │ │ └── index.vue
│ │ ├── discover
│ │ ├── archive
│ │ │ └── index.vue
│ │ ├── category
│ │ │ └── index.vue
│ │ └── tag
│ │ │ └── index.vue
│ │ ├── entertainment
│ │ ├── album
│ │ │ └── index.vue
│ │ └── talking
│ │ │ └── index.vue
│ │ ├── error-page
│ │ └── 404.vue
│ │ ├── home
│ │ ├── components
│ │ │ ├── Announcement.vue
│ │ │ ├── ArticleCard.vue
│ │ │ ├── AuthorInfo.vue
│ │ │ ├── HomeBanner.vue
│ │ │ ├── TalkingCarousel.vue
│ │ │ └── WebsiteInfo.vue
│ │ └── index.vue
│ │ ├── link
│ │ ├── components
│ │ │ ├── AddLink.vue
│ │ │ └── LinkList.vue
│ │ └── index.vue
│ │ ├── message
│ │ └── index.vue
│ │ └── user
│ │ ├── UploadOne.vue
│ │ └── index.vue
├── uno.config.js
└── vite.config.js
├── gin-blog-server
├── .gitignore
├── Dockerfile
├── README.md
├── assets
│ ├── gvb.sql
│ ├── ip2region.xdb
│ └── templates
│ │ ├── base.tpl
│ │ ├── email-verify.tpl
│ │ └── style.tpl
├── cmd
│ ├── create-superadmin
│ │ └── main.go
│ ├── create_superadmin.sh
│ ├── generate-data
│ │ └── main.go
│ ├── generate_data.sh
│ ├── main.go
│ └── run_swag.sh
├── config.docker.yml
├── config.yml
├── docs
│ ├── docs.go
│ ├── swagger.json
│ └── swagger.yaml
├── go.mod
├── go.sum
├── internal
│ ├── global
│ │ ├── config.go
│ │ ├── keys.go
│ │ └── result.go
│ ├── handle
│ │ ├── base.go
│ │ ├── cache.go
│ │ ├── cache_test.go
│ │ ├── handle_article.go
│ │ ├── handle_auth.go
│ │ ├── handle_bloginfo.go
│ │ ├── handle_category.go
│ │ ├── handle_comment.go
│ │ ├── handle_front.go
│ │ ├── handle_link.go
│ │ ├── handle_menu.go
│ │ ├── handle_message.go
│ │ ├── handle_operationlog.go
│ │ ├── handle_page.go
│ │ ├── handle_resource.go
│ │ ├── handle_role.go
│ │ ├── handle_tag.go
│ │ ├── handle_upload.go
│ │ └── handle_user.go
│ ├── helper.go
│ ├── manager.go
│ ├── manager_test.go
│ ├── middleware
│ │ ├── auth.go
│ │ ├── base.go
│ │ ├── listen_online.go
│ │ └── operation_log.go
│ ├── model
│ │ ├── article.go
│ │ ├── article_test.go
│ │ ├── auth.go
│ │ ├── auth_control.go
│ │ ├── auth_control_test.go
│ │ ├── auth_test.go
│ │ ├── category.go
│ │ ├── comment.go
│ │ ├── comment_test.go
│ │ ├── config.go
│ │ ├── config_test.go
│ │ ├── friend_link.go
│ │ ├── front.go
│ │ ├── message.go
│ │ ├── operation_log.go
│ │ ├── page.go
│ │ ├── page_test.go
│ │ ├── tag.go
│ │ ├── user.go
│ │ ├── user_test.go
│ │ ├── z_base.go
│ │ └── z_base_test.go
│ └── utils
│ │ ├── email.go
│ │ ├── encrypt.go
│ │ ├── encrypt_test.go
│ │ ├── ip.go
│ │ ├── jwt
│ │ ├── jwt.go
│ │ └── jwt_test.go
│ │ └── upload
│ │ ├── local.go
│ │ ├── oss.go
│ │ ├── qiniu.go
│ │ └── tencent.go
└── swag_init.sh
└── images
├── 前台文章列表.png
├── 前台首页.png
├── 后台文章列表.png
└── 头像.jpeg
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .vite-ssg-dist
3 | .vite-ssg-temp
4 | *.local
5 | dist
6 | dist-ssr
7 | node_modules
8 | .idea/
9 | # .vscode/
10 | *.log
11 |
12 | # Binaries for programs and plugins
13 | *.exe
14 | *.exe~
15 | *.dll
16 | *.so
17 | *.dylib
18 |
19 | # Test binary, built with `go test -c`
20 | *.test
21 |
22 | # Output of the go coverage tool, specifically when used with LiteIDE
23 | *.out
24 |
25 | # Dependency directories (remove the comment below to include it)
26 | # vendor/
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # 阶段一: 打包前后台静态资源
2 | FROM node:18-alpine3.19 AS BUILD
3 | WORKDIR /app/front
4 | COPY gin-blog-front/package*.json .
5 | RUN npm config set registry https://registry.npmmirror.com \
6 | && npm install -g pnpm \
7 | && pnpm install
8 | COPY gin-blog-front .
9 | RUN pnpm build
10 |
11 | WORKDIR /app/admin
12 | COPY gin-blog-admin .
13 | RUN pnpm install && pnpm build
14 |
15 | # 阶段二: 将静态资源部署到 Nginx
16 | FROM nginx:1.24.0-alpine
17 |
18 | # 从第一个阶段拷贝构建好的静态资源到容器
19 | COPY --from=BUILD /app/front/dist /usr/share/nginx/html
20 | COPY --from=BUILD /app/admin/dist /usr/share/nginx/html/admin
21 |
22 | # 将 Nginx 配置文件模板拷到容器中, 并执行脚本填充环境变量
23 | COPY deploy/build/web/default.conf.template /etc/nginx/conf.d/default.conf.template
24 | COPY deploy/build/web/default.conf.ssl.template /etc/nginx/conf.d/default.conf.ssl.template
25 | COPY deploy/build/web/run.sh /docker-entrypoint.sh
26 | RUN chmod a+x /docker-entrypoint.sh
27 | ENTRYPOINT ["/docker-entrypoint.sh"]
28 |
29 | CMD [ "nginx", "-g", "daemon off;" ]
30 |
31 | EXPOSE 80
32 | EXPOSE 443
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022-PRESENT szluyu99
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/deploy/.gitignore:
--------------------------------------------------------------------------------
1 | # 容器文件映射到本地, 不上传仓库
2 | start/gvb
3 |
4 | # dist
5 | build/web/dist_admin
6 | build/web/dist_blog
--------------------------------------------------------------------------------
/deploy/bootstrap.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if [ "$1" == "dev" ]; then
4 | # 开发环境, 使用本机的 pnpm 环境打包 (相对于 start/Dockerfile 的路径)
5 | export WEB_BUILD_CONTEXT="../build/web"
6 | ./build_web.sh
7 | else
8 | # 生产环境, 使用 docker 容器的 node 打包
9 | export WEB_BUILD_CONTEXT="../.."
10 | fi
11 |
12 | # 清理旧容器
13 | ./clean_docker.sh
14 |
15 | # 启动新容器
16 | cd start
17 | docker-compose up -d --build
--------------------------------------------------------------------------------
/deploy/build/mysql/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mysql:8.0
2 |
3 | # 定义工作目录
4 | ENV WORK_PATH /usr/local/work
5 | # 定义被容器自动执行的目录
6 | ENV AUTO_RUN_DIR /docker-entrypoint-initdb.d
7 | # 定义要执行的 shell 文件
8 | ENV RUN_SHELL run.sh
9 |
10 | COPY ./mysql.cnf /etc/mysql/conf.d/
11 | # 把数据库初始化数据的文件复制到工作目录
12 | COPY ./gvb.sql ${WORK_PATH}/
13 | # 把执行初始化的脚本放到 /docker-entrypoint-initdb.d 目录下
14 | COPY ./${RUN_SHELL} ${AUTO_RUN_DIR}/
15 |
16 | # 给执行文件添加可执行权限
17 | RUN chmod a+x ${AUTO_RUN_DIR}/${RUN_SHELL}
--------------------------------------------------------------------------------
/deploy/build/mysql/README.md:
--------------------------------------------------------------------------------
1 | # 二次开发指南
2 |
3 | mysql 镜像的主要作用是 **初始化数据库中数据**,核心在于 `gvb.sql` 文件
4 |
5 | 这是启动 mysql 容器后会自动执行的 sql 文件
6 |
7 | 如需更改数据库初始数据,修改 `gvb.sql` 文件
8 |
9 | > 如果已经运行过一次,需要删除原本的数据文件 `start/gvb` 目录(注意数据备份)
10 |
11 | 然后重新一键运行脚本 `./bootstrap.sh`
--------------------------------------------------------------------------------
/deploy/build/mysql/mysql.cnf:
--------------------------------------------------------------------------------
1 | [client]
2 | default-character-set=utf8mb4
3 |
4 | [mysql]
5 | default-character-set=utf8mb4
6 |
7 | [mysqld]
8 | default_authentication_plugin=mysql_native_password
9 | character-set-client-handshake=FALSE
10 | character-set-server=utf8mb4
11 | collation-server=utf8mb4_unicode_ci
--------------------------------------------------------------------------------
/deploy/build/mysql/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | mysql -uroot -p$MYSQL_ROOT_PASSWORD << EOF
4 | system echo '================Start create database gvb====================';
5 | source $WORK_PATH/gvb.sql
6 | system echo '================OK!====================';
7 | EOF
--------------------------------------------------------------------------------
/deploy/build/server/README.md:
--------------------------------------------------------------------------------
1 | # 二次开发指南
2 |
3 | 后端服务的 Dockerfile 参考 gin-blog-server 目录
4 |
5 | 直接在修改 gin-blog-server 中的后端源码,然后执行 `./bootstrap.sh`
--------------------------------------------------------------------------------
/deploy/build/web/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginx:1.25.3-alpine
2 | # 这份是用于从本地的静态资源构建镜像的 Dockerfile
3 |
4 | # 将 Nginx 配置文件模板拷到容器中
5 | COPY default.conf.template /etc/nginx/conf.d/default.conf.template
6 | COPY default.conf.ssl.template /etc/nginx/conf.d/default.conf.ssl.template
7 |
8 | # 静态资源 拷到容器
9 | ADD dist_blog/ /usr/share/nginx/html/
10 | ADD dist_admin/ /usr/share/nginx/html/admin
11 |
12 | # 初始化脚本, 根据环境变量和模板生成 Nginx 配置文件
13 | COPY ./run.sh /docker-entrypoint.sh
14 | RUN chmod a+x /docker-entrypoint.sh
15 | ENTRYPOINT ["/docker-entrypoint.sh"]
16 |
17 | # 每次容器启动时执行
18 | CMD [ "nginx", "-g", "daemon off;" ]
19 |
20 | EXPOSE 80
21 | EXPOSE 443
--------------------------------------------------------------------------------
/deploy/build/web/README.md:
--------------------------------------------------------------------------------
1 | # 二次开发指南
2 |
3 | ## 方法一:不依赖本地 pnpm 环境进行打包
4 |
5 | 执行 `./bootstrap.sh`
6 |
7 |
8 | ## 方法二:依赖本地 pnpm 环境进行打包
9 |
10 | 执行 `./bootstrap.sh dev`
11 |
--------------------------------------------------------------------------------
/deploy/build/web/default.conf.ssl.template:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | # 填写绑定证书的域名
4 | server_name ${SERVER_NAME};
5 | # http 域名转化成 https
6 | return 301 https://$host$request_uri;
7 | }
8 |
9 | server {
10 | # SSL 默认访问端口号为 443
11 | listen 443 ssl;
12 | # 绑定证书的域名
13 | server_name ${SERVER_NAME};
14 | # 证书文件的相对路径或绝对路径
15 | ssl_certificate /etc/nginx/crt/server.crt;
16 | # 私钥文件的相对路径或绝对路径
17 | ssl_certificate_key /etc/nginx/crt/server.key;
18 | ssl_session_timeout 5m;
19 | ssl_protocols TLSv1.2 TLSv1.3;
20 | ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
21 | ssl_prefer_server_ciphers on;
22 |
23 | root /usr/share/nginx/html;
24 |
25 | ######### 反向代理 start ########
26 | location /api {
27 | proxy_pass http://${BACKEND_HOST}:${SERVER_PORT};
28 | # 获取真实 IP
29 | proxy_set_header Host $http_host;
30 | proxy_set_header X-Real-IP $remote_addr;
31 | proxy_set_header X-Forwarded-For $remote_addr;
32 | proxy_set_header X-Forwarded-Proto $remote_addr;
33 |
34 | client_max_body_size 40M;
35 | }
36 | # Gin 处理静态资源, Nginx 反向代理 Gin (本地文件上传才需要)
37 | location /public/uploaded {
38 | proxy_pass http://${BACKEND_HOST}:${SERVER_PORT}/public/uploaded;
39 | }
40 | location /admin/public/uploaded {
41 | proxy_pass http://${BACKEND_HOST}:${SERVER_PORT}/public/uploaded;
42 | }
43 | ######### 反向代理 end ########
44 |
45 | ######### 静态资源 start ########
46 | location / {
47 | try_files $uri $uri/ /index.html;
48 | }
49 | location /admin {
50 | try_files $uri $uri/ /admin/index.html;
51 | }
52 | location ~ .*\.(js|json|css)$ {
53 | gzip on;
54 | gzip_static on;
55 | gzip_min_length 1k;
56 | gzip_http_version 1.1;
57 | gzip_comp_level 9;
58 | gzip_types text/css application/javascript application/json;
59 | root /usr/share/nginx/html;
60 | }
61 | ######### 静态资源 end ########
62 | }
--------------------------------------------------------------------------------
/deploy/build/web/default.conf.template:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name ${SERVER_NAME};
4 | root /usr/share/nginx/html;
5 |
6 | ######### 反向代理 start ########
7 | location /api {
8 | proxy_pass http://${BACKEND_HOST}:${SERVER_PORT};
9 | # 获取真实 IP
10 | proxy_set_header Host $http_host;
11 | proxy_set_header X-Real-IP $remote_addr;
12 | proxy_set_header X-Forwarded-For $remote_addr;
13 | proxy_set_header X-Forwarded-Proto $remote_addr;
14 |
15 | client_max_body_size 40M;
16 | }
17 | # Gin 处理静态资源, Nginx 反向代理 Gin (本地文件上传才需要)
18 | location /public/uploaded {
19 | proxy_pass http://${BACKEND_HOST}:${SERVER_PORT}/public/uploaded;
20 | }
21 | location /admin/public/uploaded {
22 | proxy_pass http://${BACKEND_HOST}:${SERVER_PORT}/public/uploaded;
23 | }
24 | ######### 反向代理 end ########
25 |
26 | ######### 静态资源 start ########
27 | location / {
28 | try_files $uri $uri/ /index.html;
29 | }
30 | location /admin {
31 | try_files $uri $uri/ /admin/index.html;
32 | }
33 | location ~ .*\.(js|json|css)$ {
34 | gzip on;
35 | gzip_static on;
36 | gzip_min_length 1k;
37 | gzip_http_version 1.1;
38 | gzip_comp_level 9;
39 | gzip_types text/css application/javascript application/json;
40 | root /usr/share/nginx/html;
41 | }
42 | ######### 静态资源 end ########
43 | }
--------------------------------------------------------------------------------
/deploy/build/web/run.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | # 将模板 conf 配置文件注入对应环境变量, 生成到指定文件夹
4 | set -eu
5 | if [ "$USE_HTTPS" == "true" ]; then
6 | envsubst '${SERVER_NAME} ${BACKEND_HOST} ${SERVER_PORT}' < /etc/nginx/conf.d/default.conf.ssl.template > /etc/nginx/conf.d/default.conf
7 | else
8 | envsubst '${SERVER_NAME} ${BACKEND_HOST} ${SERVER_PORT}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
9 | fi
10 | # rm /etc/nginx/conf.d/default.conf.template
11 | # rm /etc/nginx/conf.d/default.conf.ssl.template
12 | exec "$@"
--------------------------------------------------------------------------------
/deploy/build_web.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # 未安装 pnpm 则安装
4 | if ! command -v pnpm &> /dev/null; then
5 | npm install -g pnpm
6 | else
7 | echo "pnpm 已安装"
8 | fi
9 |
10 | # 打包 gin-blog-front 并移到 docker 部署目录
11 | cd ../gin-blog-front
12 | pnpm install
13 | pnpm build
14 | rm -rf ../deploy/build/web/dist_blog
15 | mv ./dist ../deploy/build/web/dist_blog
16 |
17 | # 打包 gin-blog-admin 并移到 docker 部署目录
18 | cd ../gin-blog-admin
19 | pnpm install
20 | pnpm build
21 | rm -rf ../deploy/build/web/dist_admin
22 | mv ./dist ../deploy/build/web/dist_admin
--------------------------------------------------------------------------------
/deploy/clean_docker.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # 清理本项目构建的 Docker 容器
3 |
4 | stop_and_remove_container() {
5 | container_name=$1
6 | if docker ps -a --format '{{.Names}}' | grep -q "$container_name"; then
7 | docker stop $container_name
8 | docker rm $container_name
9 | echo "已经停止并删除容器 $container_name"
10 | else
11 | echo "未找到容器 $container_name"
12 | fi
13 | }
14 |
15 | stop_and_remove_container "gvb-web"
16 | stop_and_remove_container "gvb-server"
17 | stop_and_remove_container "gvb-redis"
18 | stop_and_remove_container "gvb-mysql"
19 |
20 | echo "本项目相关 Docker 容器清理完成"
--------------------------------------------------------------------------------
/deploy/start/.env:
--------------------------------------------------------------------------------
1 | # https://docs.docker.com/compose/environment-variables/
2 | # docker-compose.yml 同目录下的 .env 文件会被加载为其环境变量
3 |
4 | WEB_BUILD_CONTEXT=../..
5 |
6 | # 数据存储的文件夹位置 (默认在 start 目录下生成 gvb 文件夹)
7 | DATA_DIRECTORY=./gvb
8 |
9 | # Redis
10 | REDIS_PORT=63799
11 | REDIS_PASSWORD=66554321
12 |
13 | # MySQL
14 | MYSQL_PORT=33066
15 | MYSQL_ROOT_PASSWORD=12345566
16 |
17 | # 后端服务配置
18 | SERVER_PORT = 8765 # 服务端口
19 |
20 | # 前端服务配置
21 | # 要开启 https 请在 start 目录添加有效的证书文件: server.crt 和 server.key
22 | USE_HTTPS=false
23 | SERVER_NAME=localhost # 域名 或 localhost
24 |
25 | # Docker Network: 一般不需要变, 除非发生冲突
26 | SUBNET=172.12.0.0/24
27 | REDIS_HOST=172.12.0.2
28 | MYSQL_HOST=172.12.0.3
29 | BACKEND_HOST=172.12.0.4
30 | FRONTEND_HOST=172.12.0.6
--------------------------------------------------------------------------------
/deploy/start/README.md:
--------------------------------------------------------------------------------
1 | 建议执行 deploy 目录下的 `bootstrap.sh` 脚本,会做一些清理旧容器等功能
2 |
3 | 也可以进入 start 目录后,执行以下命令:
4 |
5 | ```bash
6 | docker-compose ud -d
7 | ```
8 |
--------------------------------------------------------------------------------
/deploy/start/server.crt:
--------------------------------------------------------------------------------
1 | # 如果部署在服务上, 并且需要开启 https 时需要提供证书
--------------------------------------------------------------------------------
/deploy/start/server.key:
--------------------------------------------------------------------------------
1 | # 如果部署在服务上, 并且需要开启 https 时需要提供证书#
--------------------------------------------------------------------------------
/gin-blog-admin/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/gin-blog-admin/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://editorconfig.org/
2 | # 当前是根目录, 无需再往上层寻找
3 | root = true
4 |
5 | [*] # 匹配所有的文件
6 | charset = utf-8 # 文件编码
7 | indent_style = space # 空格缩进
8 | indent_size = 2 # 缩进空格为 2
9 | end_of_line = lf # 文件换行符是 Linux 的 '\n'
10 | trim_trailing_whitespace = true # 不保留行末的空格
11 | insert_final_newline = true # 文件末尾添加一个空行
12 | spaces_around_operators = true # 运算符两遍都有空格
13 | # max_line_length = 100
14 |
15 | [*.md] # 只对 .md 文件生效
16 | trim_trailing_whitespace = false
17 |
--------------------------------------------------------------------------------
/gin-blog-admin/.env:
--------------------------------------------------------------------------------
1 | # 所有情况下都会加载
2 | VITE_TITLE = 'Gin Blog Admin'
3 |
--------------------------------------------------------------------------------
/gin-blog-admin/.env.development:
--------------------------------------------------------------------------------
1 | # development 模式下会加载
2 | # 资源公共路径, 需要以 / 开头和结尾
3 | VITE_PUBLIC_PATH = '/'
4 |
5 | # 服务器路径(用于本地文件上传加载图片)
6 | VITE_SERVER_URL = 'http://localhost:8765' # 静态资源由后端服务挂载
7 |
8 | # 基础 API
9 | VITE_BASE_API = '/api'
10 |
11 | # 是否后端生成路由
12 | VITE_BACK_ROUTER = true
13 |
14 | # 登录时是否需要验证码, 需要在 index.html 引入 TCaptcha.js
15 | VITE_USE_CAPTCHA = false
16 |
--------------------------------------------------------------------------------
/gin-blog-admin/.env.production:
--------------------------------------------------------------------------------
1 | # production 模式下会加载
2 | # 资源公共路径, 需要以 / 开头和结尾
3 | VITE_PUBLIC_PATH = '/admin/'
4 |
5 | # 服务器路径(用于本地文件上传加载图片)
6 | VITE_SERVER_URL = 'http://localhost' # 静态资源由后端服务挂载
7 |
8 | # 基础 API: 设置代理就写代理 API, 否则写完整 URL, 参考 setting/proxy-config.js
9 | VITE_BASE_API = '/api'
10 |
11 | # 是否后端生成路由
12 | VITE_BACK_ROUTER = true
13 |
14 | # 登录时是否需要验证码, 需要在 index.html 引入 TCaptcha.js
15 | VITE_USE_CAPTCHA = false
16 |
--------------------------------------------------------------------------------
/gin-blog-admin/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | # Dependency directories
11 | node_modules/
12 | jspm_packages/
13 |
14 | # Build
15 | dist
16 | dist-ssr
17 | *.local
18 |
19 | # MacOS
20 | .DS_Store
21 |
22 | # Vim swap files
23 | *.swp
24 |
25 | # Editor directories and files
26 | .idea
27 | *.suo
28 | *.ntvs*
29 | *.njsproj
30 | *.sln
31 | *.sw?
32 |
33 | # rollup-plugin-visualizer
34 | stats.html
35 |
36 | # Dotenv
37 | .env*
38 | *.local
39 | !.env.sample
40 |
--------------------------------------------------------------------------------
/gin-blog-admin/.npmrc:
--------------------------------------------------------------------------------
1 | shamefully-hoist=true
2 | strict-peer-dependencies=false
3 | registry = https://registry.npmmirror.com
4 |
--------------------------------------------------------------------------------
/gin-blog-admin/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "antfu.iconify",
4 | "antfu.unocss",
5 | "vue.volar",
6 | "dbaeumer.vscode-eslint",
7 | "EditorConfig.EditorConfig"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/gin-blog-admin/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | // Enable the ESlint flat config support
3 | "eslint.experimental.useFlatConfig": true,
4 |
5 | // Disable the default formatter, use eslint instead
6 | "prettier.enable": false,
7 | "editor.formatOnSave": false,
8 |
9 | // Auto fix
10 | "editor.codeActionsOnSave": {
11 | "source.fixAll.eslint": "explicit",
12 | "source.organizeImports": "never"
13 | },
14 |
15 | // Enable eslint for all supported languages
16 | "eslint.validate": [
17 | "javascript",
18 | "javascriptreact",
19 | "typescript",
20 | "typescriptreact",
21 | "vue",
22 | "html",
23 | "markdown",
24 | "json",
25 | "jsonc",
26 | "yaml"
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/gin-blog-admin/README.md:
--------------------------------------------------------------------------------
1 | 本后台项目基于这个项目骨架:[https://github.com/zclzone/vue-naive-admin](https://github.com/zclzone/vue-naive-admin), 感谢开源作者的奉献。
2 |
3 | ## 项目路由
4 |
5 | 后端路由:由后端传来一个基础的菜单数组, 前端组装成可访问的路由格式
6 |
7 | 前端路由: 加载前端写死的路由, 根据其 meta.requireAuth 判断是否需要鉴权, 同时由前端判断角色
8 |
9 | ## 相比 Vue Naive Admin 项目的变化
10 |
11 | 原则:一个问题不需要太多解决方案,所以本项目中只保留最常用的解决方案,如果实在不能解决需求,需要自行添加
12 |
13 | 基于 Vue Naive Admin, 本项目在其基础上更新了很多,主要是为了精简项目, 对接后端, 大致列出如下:
14 |
15 | 整体结构:
16 | - 去除 Mock: 因为项目有真实的后端, 无需 Mock
17 | - 去除 build 文件夹, 因为去除了很多插件 (unplugin 全部去除), 所以并不必须
18 | - 对接真实后端数据, 添加后端路由等功能
19 |
20 | 插件相关:
21 | - 去除 unplugin 系列所有插件: `unplugin-auto-import`, `unplugin-icons`, `unplugin-vue-components`
22 | - 去除 `vite-plugin-html`, `vite-plugin-mock`, `vite-plugin-svg-icons`: 本项目中未使用
23 | - 去除 prettier, 统一使用 eslint
24 | - 去除 `@commitlint/cli`, `@commitlint/config-conventional`: 非必须, 追求精简
25 | - 去除 `lint-staged`, `husky`: 本项目是大仓库的子项目, 不需要提交前检查
26 | - 去除 `@unocss/preset-rem-to-px` 插件,一般情况下不需要转换字体
27 | - 添加 taze 插件: 用于升级依赖
28 |
29 | 去除 unplugin 系插件的主要原因有以下:
30 | - 这些插件并不涉及业务功能上的必须, 只是为了方便开发
31 | - 为了降低项目的耦合性, 以及项目对插件的依赖性, 提高项目移植的便捷性, 去除这些插件
32 | - 这些插件某种程度上可以让单人开发者的开发效率提高, 但是根据经验发现不便于维护, 对其他人不友好
33 | - 可能会导致一些奇奇怪怪的问题
34 |
35 | UnoCSS - `uno.config.js` 中: 以下预设都不是必须, 追求精简
36 | - 去除 presetAttributify 预设
37 | - 去除 shortcuts
38 | - 去除 rules
39 | - 采用 @unocss/reset 代替 reset.css
40 | - 图标统一使用 UnoCSS 的使用方法, 使用 presetIcons 预设
41 |
42 | ## 代码风格控制
43 |
44 | 关于项目中为什么不使用 Prettier,参考 Antfu 大佬: [为什么我不使用 Prettier](https://antfu.me/posts/why-not-prettier-zh)
45 |
46 | Eslint 方案采用 [https://github.com/antfu/eslint-config](https://github.com/antfu/eslint-config),最大化减少配置
47 |
--------------------------------------------------------------------------------
/gin-blog-admin/eslint.config.js:
--------------------------------------------------------------------------------
1 | import antfu from '@antfu/eslint-config'
2 |
3 | export default antfu({
4 | unocss: true,
5 | rules: {
6 | 'no-console': 'warn',
7 | 'curly': 'off',
8 | '@typescript-eslint/brace-style': 'off',
9 | 'unused-imports/no-unused-imports': 'off',
10 | 'node/prefer-global/process': 'off',
11 | },
12 | })
13 |
--------------------------------------------------------------------------------
/gin-blog-admin/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Gin Vue Blog
17 |
18 |
19 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |

34 |
42 |
Gin Vue Blog
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/gin-blog-admin/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./",
4 | "target": "ESNext",
5 | "moduleResolution": "node",
6 | "strict": true,
7 | "jsx": "preserve",
8 | "paths": {
9 | "@/*": ["src/*"],
10 | "~/*": ["./*"]
11 | }
12 | },
13 | "exclude": ["node_modules", "dist"]
14 | }
15 |
--------------------------------------------------------------------------------
/gin-blog-admin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "private": true,
4 | "scripts": {
5 | "dev": "vite",
6 | "build": "vite build",
7 | "preview": "vite preview",
8 | "lint": "eslint .",
9 | "up": "taze major -I"
10 | },
11 | "dependencies": {
12 | "@vueuse/core": "^10.7.0",
13 | "axios": "^1.6.2",
14 | "dayjs": "^1.11.10",
15 | "highlight.js": "^11.9.0",
16 | "md-editor-v3": "^4.9.0",
17 | "naive-ui": "^2.35.0",
18 | "pinia": "^2.1.7",
19 | "pinia-plugin-persistedstate": "^3.2.0",
20 | "unocss": "^0.58.0",
21 | "vue": "^3.3.11",
22 | "vue-router": "^4.2.5",
23 | "xlsx": "^0.18.5"
24 | },
25 | "devDependencies": {
26 | "@antfu/eslint-config": "^2.4.5",
27 | "@iconify/json": "^2.2.155",
28 | "@iconify/vue": "^4.1.1",
29 | "@unocss/eslint-plugin": "^0.58.0",
30 | "@unocss/reset": "^0.53.6",
31 | "@vitejs/plugin-vue": "^4.5.2",
32 | "eslint": "^8.55.0",
33 | "rollup-plugin-visualizer": "^5.11.0",
34 | "sass": "^1.69.5",
35 | "taze": "^0.8.5",
36 | "vite": "^4.5.1",
37 | "vite-plugin-compression": "^0.5.1"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/gin-blog-admin/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/gin-blog-admin/public/image/404.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szluyu99/gin-vue-blog/61dd11ccd296e8642a318ada3ef7b3f7776d2410/gin-blog-admin/public/image/404.webp
--------------------------------------------------------------------------------
/gin-blog-admin/public/image/login_banner.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szluyu99/gin-vue-blog/61dd11ccd296e8642a318ada3ef7b3f7776d2410/gin-blog-admin/public/image/login_banner.webp
--------------------------------------------------------------------------------
/gin-blog-admin/public/image/login_bg.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szluyu99/gin-vue-blog/61dd11ccd296e8642a318ada3ef7b3f7776d2410/gin-blog-admin/public/image/login_bg.webp
--------------------------------------------------------------------------------
/gin-blog-admin/public/image/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/gin-blog-admin/public/resource/loading.css:
--------------------------------------------------------------------------------
1 | .loading-container {
2 | position: fixed;
3 | left: 0;
4 | top: 0;
5 | display: flex;
6 | flex-direction: column;
7 | justify-content: center;
8 | align-items: center;
9 | width: 100%;
10 | height: 100%;
11 | }
12 |
13 | .loading-logo {
14 | height: 120px;
15 | }
16 |
17 | .loading-spin__container {
18 | width: 56px;
19 | height: 56px;
20 | margin: 36px 0;
21 | }
22 |
23 | .loading-spin {
24 | position: relative;
25 | height: 100%;
26 | animation: loadingSpin 1s linear infinite;
27 | }
28 |
29 | .left-0 {
30 | left: 0;
31 | }
32 | .right-0 {
33 | right: 0;
34 | }
35 | .top-0 {
36 | top: 0;
37 | }
38 | .bottom-0 {
39 | bottom: 0;
40 | }
41 |
42 | .loading-spin-item {
43 | position: absolute;
44 | height: 16px;
45 | width: 16px;
46 | background-color: var(--primary-color);
47 | border-radius: 8px;
48 | -webkit-animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
49 | animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
50 | }
51 |
52 | @keyframes loadingSpin {
53 | from {
54 | -webkit-transform: rotate(0deg);
55 | transform: rotate(0deg);
56 | }
57 | to {
58 | -webkit-transform: rotate(360deg);
59 | transform: rotate(360deg);
60 | }
61 | }
62 |
63 | @keyframes loadingPulse {
64 | 0%, 100% {
65 | opacity: 1;
66 | }
67 | 50% {
68 | opacity: .5;
69 | }
70 | }
71 |
72 | .loading-delay-500 {
73 | -webkit-animation-delay: 500ms;
74 | animation-delay: 500ms;
75 | }
76 | .loading-delay-1000 {
77 | -webkit-animation-delay: 1000ms;
78 | animation-delay: 1000ms;
79 | }
80 | .loading-delay-1500 {
81 | -webkit-animation-delay: 1500ms;
82 | animation-delay: 1500ms;
83 | }
84 |
85 | .loading-title {
86 | font-size: 28px;
87 | font-weight: 500;
88 | color: var(--primary-color);
89 | }
90 |
--------------------------------------------------------------------------------
/gin-blog-admin/public/resource/loading.js:
--------------------------------------------------------------------------------
1 | function addThemeColorCssVars() {
2 | const key = '__THEME_COLOR__'
3 | const defaultColor = '#316c72'
4 | const themeColor = localStorage.getItem(key) || defaultColor
5 | const cssVars = `--primary-color: ${themeColor}`
6 | document.documentElement.style.cssText = cssVars
7 | }
8 |
9 | addThemeColorCssVars()
10 |
--------------------------------------------------------------------------------
/gin-blog-admin/public/resource/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/App.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/assets/config.js:
--------------------------------------------------------------------------------
1 | export const config = {
2 | _APP_ID: '101878726',
3 | QQ_REDIRECT_URI: 'https://www.talkxj.com/oauth/login/qq',
4 | WEIBO_APP_ID: '4039197195',
5 | WEIBO_REDIRECT_URI: 'https://www.talkxj.com/oauth/login/weibo',
6 | TENCENT_CAPTCHA: '2088053498',
7 | }
8 |
9 | // 登录方式选项
10 | export const loginTypeOptions = [
11 | { label: '邮箱', value: 1 },
12 | { label: 'QQ', value: 2 },
13 | { label: '微博', value: 3 },
14 | ]
15 |
16 | export const loginTypeMap = {
17 | 1: { name: '邮箱', tag: 'success' },
18 | 2: { name: 'QQ', tag: 'info' },
19 | 3: { name: '微博', tag: 'warning' },
20 | }
21 |
22 | // 文章类型选项
23 | export const articleTypeOptions = [
24 | { label: '原创', value: 1 },
25 | { label: '转载', value: 2 },
26 | { label: '翻译', value: 3 },
27 | ]
28 |
29 | export const articleTypeMap = {
30 | 1: { name: '原创', tag: 'error' },
31 | 2: { name: '转载', tag: 'success' },
32 | 3: { name: '翻译', tag: 'warning' },
33 | }
34 |
35 | // 评论类型选项
36 | export const commentTypeOptions = [
37 | { label: '文章', value: 1 },
38 | { label: '友链', value: 2 },
39 | { label: '说说', value: 3 },
40 | ]
41 |
42 | export const commentTypeMap = {
43 | 1: { name: '文章', tag: 'info' },
44 | 2: { name: '友链', tag: 'warning' },
45 | 3: { name: '说说', tag: 'error' },
46 | }
47 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/assets/themes.js:
--------------------------------------------------------------------------------
1 | // TODO: 响应式
2 | export default {
3 | header: {
4 | height: 60,
5 | },
6 | tags: {
7 | visible: true,
8 | height: 50,
9 | },
10 | naiveThemeOverrides: {
11 | common: {
12 | primaryColor: '#316C72FF',
13 | primaryColorHover: '#316C72E3',
14 | primaryColorPressed: '#2B4C59FF',
15 | primaryColorSuppl: '#316C72E3',
16 |
17 | infoColor: '#2080F0FF',
18 | infoColorHover: '#4098FCFF',
19 | infoColorPressed: '#1060C9FF',
20 | infoColorSuppl: '#4098FCFF',
21 |
22 | successColor: '#18A058FF',
23 | successColorHover: '#36AD6AFF',
24 | successColorPressed: '#0C7A43FF',
25 | successColorSuppl: '#36AD6AFF',
26 |
27 | warningColor: '#F0A020FF',
28 | warningColorHover: '#FCB040FF',
29 | warningColorPressed: '#C97C10FF',
30 | warningColorSuppl: '#FCB040FF',
31 |
32 | errorColor: '#D03050FF',
33 | errorColorHover: '#DE576DFF',
34 | errorColorPressed: '#AB1F3FFF',
35 | errorColorSuppl: '#DE576DFF',
36 | },
37 | },
38 | }
39 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/components/UploadOne.vue:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 |
46 |
52 |
53 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | 点击或者拖动文件到该区域来上传
70 |
71 |
72 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/components/common/AppPage.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
17 |
18 |
19 |
20 |
76 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/components/common/CommonPage.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
18 |
19 |
20 |
21 | {{ title || $route.meta?.title }}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/components/common/TheFooter.vue:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
13 |
14 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/components/crud/CrudModal.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
32 |
33 |
34 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/components/crud/QueryItem.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/components/icon/IconPicker.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
32 |
33 |
34 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | 更多图标去
49 |
50 | Icones
51 |
52 | 查看
53 |
54 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/components/icon/TheIcon.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/composables/index.js:
--------------------------------------------------------------------------------
1 | export * from './useCRUD'
2 | export * from './useForm'
3 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/composables/useForm.js:
--------------------------------------------------------------------------------
1 | import { ref } from 'vue'
2 |
3 | /**
4 | * 可复用的表单对象
5 | * @param {any} initForm 表单初始值
6 | */
7 | export function useForm(initForm = {}) {
8 | const formRef = ref(null)
9 | const formModel = ref({ ...initForm })
10 |
11 | const validation = async () => {
12 | try {
13 | await formRef.value?.validate()
14 | return true
15 | }
16 | catch (error) {
17 | return false
18 | }
19 | }
20 |
21 | const rules = {
22 | required: {
23 | required: true,
24 | message: '此为必填项',
25 | trigger: ['blur', 'change'],
26 | },
27 | }
28 | return { formRef, formModel, validation, rules }
29 | }
30 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/layout/header/components/BreadCrumb.vue:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 |
25 |
30 |
31 | {{ item.meta?.title }}
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/layout/header/components/FullScreen.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/layout/header/components/GithubSite.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/layout/header/components/MenuCollapse.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/layout/header/components/ThemeMode.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/layout/header/components/UserAvatar.vue:
--------------------------------------------------------------------------------
1 |
41 |
42 |
43 |
44 |
45 |
![]()
46 |
{{ userStore.nickname }}
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/layout/header/components/Watermark.vue:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
28 |
29 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/layout/header/index.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/layout/index.vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 |
28 |
36 |
37 |
38 |
39 |
40 |
41 |
47 |
48 |
51 |
52 |
53 |
54 |
55 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/layout/sidebar/components/SideLogo.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |

12 |
13 |
17 | {{ title }}
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/layout/sidebar/index.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/main.js:
--------------------------------------------------------------------------------
1 | import '@unocss/reset/tailwind.css'
2 | import 'uno.css'
3 |
4 | import { createApp } from 'vue'
5 | import App from './App.vue'
6 | import { setupNaiveDiscreteApi, setupNaiveUnocss } from './utils'
7 | import { setupRouter } from './router'
8 | import { setupStore } from './store'
9 |
10 | async function bootstrap() {
11 | const app = createApp(App)
12 | setupStore(app) // 优先级最高
13 | setupNaiveUnocss()
14 | setupNaiveDiscreteApi()
15 | await setupRouter(app)
16 | app.mount('#app')
17 | }
18 |
19 | bootstrap()
20 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/router/guard.js:
--------------------------------------------------------------------------------
1 | import { useAuthStore } from '@/store'
2 |
3 | export function setupRouterGuard(router) {
4 | createPageLoadingGuard(router)
5 | createPermissionGuard(router)
6 | createPageTitleGuard(router)
7 | }
8 |
9 | /**
10 | * 根据导航设置顶部加载条的状态
11 | */
12 | function createPageLoadingGuard(router) {
13 | router.beforeEach(() => window.$loadingBar?.start())
14 | router.afterEach(() => setTimeout(() => window.$loadingBar?.finish(), 200))
15 | // 在导航期间每次发生未捕获的错误时都会调用该处理程序
16 | router.onError(() => window.$loadingBar?.error())
17 | }
18 |
19 | /**
20 | * 根据有无 Token 判断能否访问页面
21 | */
22 | function createPermissionGuard(router) {
23 | // const base = import.meta.env.VITE_BASE_URL
24 | // 路由前置守卫: 根据有没有 Token 判断前往哪个页面
25 | router.beforeEach(async (to) => {
26 | const { token } = useAuthStore()
27 |
28 | // 没有 Token
29 | if (!token) {
30 | // login 和 404 不需要 token 即可访问
31 | if (['/login', '/404'].includes(to.path)) {
32 | return true
33 | }
34 |
35 | window.$message.error('没有 Token,请先登录!')
36 | // 重定向到登录页, 并且携带 redirect 参数, 登录后自动重定向到原本的目标页面
37 | return { name: 'Login', query: { ...to.query, redirect: to.path } }
38 | }
39 |
40 | // 有 Token 的时候无需访问登录页面
41 | if (to.name === 'Login') {
42 | window.$message.success('已登录,无需重复登录!')
43 | return { path: '/' }
44 | }
45 |
46 | // 能在路由中找到, 则正常访问
47 | if (router.getRoutes().find(e => e.name === to.name)) {
48 | return true
49 | }
50 |
51 | // TODO: 刷新 Token
52 | // await refreshAccessToken()
53 |
54 | // TODO: 判断是无权限还是 404
55 | return { name: '404', query: { path: to.fullPath } }
56 | })
57 | }
58 |
59 | /**
60 | * 根据路由元信息设置页面标题
61 | */
62 | function createPageTitleGuard(router) {
63 | const baseTitle = import.meta.env.VITE_TITLE
64 | router.afterEach((to) => {
65 | const pageTitle = to.meta?.title
66 | document.title = pageTitle ? `${pageTitle} | ${baseTitle}` : baseTitle
67 | })
68 | }
69 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/router/index.js:
--------------------------------------------------------------------------------
1 | import { createRouter, createWebHistory } from 'vue-router'
2 |
3 | import { basicRoutes } from './routes'
4 | import { setupRouterGuard } from './guard'
5 |
6 | import { useAuthStore, usePermissionStore, useUserStore } from '@/store'
7 |
8 | export const router = createRouter({
9 | history: createWebHistory(import.meta.env.VITE_PUBLIC_PATH), // '/admin'
10 | routes: basicRoutes,
11 | scrollBehavior: () => ({ left: 0, top: 0 }),
12 | })
13 |
14 | /**
15 | * 初始化路由
16 | */
17 | export async function setupRouter(app) {
18 | await addDynamicRoutes()
19 | setupRouterGuard(router)
20 | app.use(router)
21 | }
22 |
23 | /**
24 | * ! 添加动态路由: 根据配置由前端或后端生成路由
25 | */
26 | export async function addDynamicRoutes() {
27 | const authStore = useAuthStore()
28 |
29 | if (!authStore.token) {
30 | authStore.toLogin()
31 | return
32 | }
33 |
34 | // 有 token 的情况
35 | try {
36 | const userStore = useUserStore()
37 | const permissionStore = usePermissionStore()
38 |
39 | // userId 不存在, 则调用接口根据 token 获取用户信息
40 | if (!userStore.userId) {
41 | await userStore.getUserInfo()
42 | }
43 |
44 | // 根据环境变量中的值决定前端生成路由还是后端路由
45 | const accessRoutes = JSON.parse(import.meta.env.VITE_BACK_ROUTER)
46 | ? await permissionStore.generateRoutesBack() // ! 后端生成路由
47 | : permissionStore.generateRoutesFront(['admin']) // ! 前端生成路由 (根据角色), 待完善
48 | console.log(accessRoutes)
49 |
50 | // 将当前没有的路由添加进去
51 | accessRoutes.forEach(route => !router.hasRoute(route.name) && router.addRoute(route))
52 | }
53 | catch (err) {
54 | console.error('addDynamicRoutes Error: ', err)
55 | }
56 | }
57 |
58 | /**
59 | * 重置路由
60 | */
61 | export async function resetRouter() {
62 | router.getRoutes().forEach((route) => {
63 | const name = route.name
64 | if (!basicRoutes.some(e => e.name === name) && router.hasRoute(name)) {
65 | router.removeRoute(name)
66 | }
67 | })
68 | }
69 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/router/routes.js:
--------------------------------------------------------------------------------
1 | // const Layout = () => import('@/layout/index.vue')
2 |
3 | // 基础路由, 无需权限, 总是会注册到最终路由中
4 | export const basicRoutes = [
5 | // TODO: 如何去除这个代码?
6 | // {
7 | // name: '',
8 | // path: '/',
9 | // component: Layout,
10 | // redirect: '/home', // 默认跳转首页
11 | // isHidden: true,
12 | // meta: { order: 0 },
13 | // },
14 | {
15 | name: 'Login',
16 | path: '/login',
17 | component: () => import('@/views/Login.vue'),
18 | isHidden: true,
19 | meta: {
20 | title: '登录页',
21 | },
22 | },
23 | {
24 | name: '404',
25 | path: '/404',
26 | component: () => import('@/views/error-page/404.vue'),
27 | isHidden: true,
28 | meta: {
29 | title: '错误页',
30 | },
31 | },
32 | ]
33 |
34 | // 前端控制路由: 加载 views 下每个模块的 routes.js 文件
35 | const routeModules = import.meta.glob('@/views/**/route.js', { eager: true })
36 | const asyncRoutes = []
37 | Object.keys(routeModules).forEach((key) => {
38 | asyncRoutes.push(routeModules[key].default)
39 | })
40 |
41 | // 加载 views 下每个模块的 index.vue 文件
42 | const vueModules = import.meta.glob('@/views/**/index.vue')
43 |
44 | export {
45 | asyncRoutes,
46 | vueModules,
47 | }
48 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { createPinia } from 'pinia'
2 |
3 | // https://github.com/prazdevs/pinia-plugin-persistedstate
4 | // pinia 数据持久化,解决刷新数据丢失的问题
5 | import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
6 |
7 | export function setupStore(app) {
8 | const pinia = createPinia()
9 | pinia.use(piniaPluginPersistedstate)
10 | app.use(pinia)
11 | }
12 |
13 | export * from './modules/permission'
14 | export * from './modules/tag'
15 | export * from './modules/theme'
16 | export * from './modules/user'
17 | export * from './modules/auth'
18 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/store/modules/auth.js:
--------------------------------------------------------------------------------
1 | import { unref } from 'vue'
2 | import { defineStore } from 'pinia'
3 | import { usePermissionStore, useTagStore, useUserStore } from '@/store'
4 | import { resetRouter, router } from '@/router'
5 | import api from '@/api'
6 |
7 | export const useAuthStore = defineStore('auth', {
8 | persist: {
9 | key: 'gvb_admin_auth',
10 | paths: ['token'],
11 | },
12 | state: () => ({
13 | token: null,
14 | }),
15 | actions: {
16 | setToken(token) {
17 | this.token = token
18 | },
19 | toLogin() {
20 | const currentRoute = unref(router.currentRoute)
21 | router.replace({
22 | path: '/login',
23 | query: currentRoute.query,
24 | })
25 | },
26 | resetLoginState() {
27 | useUserStore().$reset()
28 | usePermissionStore().$reset()
29 | useTagStore().$reset()
30 | resetRouter()
31 | this.$reset()
32 | },
33 | /**
34 | * 主动退出登录
35 | */
36 | async logout() {
37 | await api.logout()
38 | this.resetLoginState()
39 | this.toLogin()
40 | window.$message.success('您已经退出登录!')
41 | },
42 | /**
43 | * TODO: 被强制退出
44 | */
45 | async forceOffline() {
46 | this.resetLoginState()
47 | this.toLogin()
48 | // window.$message.error('您已经被强制下线!')
49 | },
50 | },
51 | })
52 |
53 | // function toLoginWithQuery() {
54 | // const currentRoute = unref(router.currentRoute)
55 | // // 跳转回去时记录 redirect 到 query 上
56 | // const needRedirect = !currentRoute.meta.requireAuth && !['/404', '/login'].includes(currentRoute.path)
57 | // router.replace({
58 | // path: '/login',
59 | // query: needRedirect ? { ...currentRoute.query, redirect: currentRoute.path } : {},
60 | // })
61 | // }
62 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/store/modules/theme.js:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia'
2 | import { useDark } from '@vueuse/core'
3 |
4 | const isDark = useDark()
5 | export const useThemeStore = defineStore('theme-store', {
6 | persist: {
7 | key: 'gvb_admin_theme',
8 | paths: ['collapsed', 'watermarked'],
9 | },
10 | state: () => ({
11 | collapsed: false, // 侧边栏折叠
12 | watermarked: false, // 水印
13 | darkMode: isDark, // 黑暗模式
14 | }),
15 | actions: {
16 | switchWatermark() {
17 | this.watermarked = !this.watermarked
18 | },
19 | switchCollapsed() {
20 | this.collapsed = !this.collapsed
21 | },
22 | switchDarkMode() {
23 | this.darkMode = !this.darkMode
24 | },
25 | },
26 | })
27 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/store/modules/user.js:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia'
2 | import { convertImgUrl } from '@/utils'
3 | import api from '@/api'
4 |
5 | // 用户全局变量
6 | export const useUserStore = defineStore('user', {
7 | state: () => ({
8 | userInfo: {
9 | id: null,
10 | nickname: '',
11 | avatar: '',
12 | intro: '',
13 | website: '',
14 | // roles: [], // TODO: 后端返回 roles
15 | },
16 | }),
17 | getters: {
18 | userId: state => state.userInfo.id,
19 | nickname: state => state.userInfo.nickname,
20 | intro: state => state.userInfo.intro,
21 | website: state => state.userInfo.website,
22 | avatar: state => convertImgUrl(state.userInfo.avatar),
23 | // roles: state => state.userInfo.roles,
24 | },
25 | actions: {
26 | async getUserInfo() {
27 | try {
28 | const resp = await api.getUserInfo()
29 | this.userInfo = resp.data
30 | return Promise.resolve(resp.data)
31 | }
32 | catch (err) {
33 | return Promise.reject(err)
34 | }
35 | },
36 | },
37 | })
38 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/utils/http.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { useAuthStore } from '@/store'
3 |
4 | export const request = axios.create(
5 | {
6 | baseURL: import.meta.env.VITE_BASE_API,
7 | timeout: 12000,
8 | },
9 | )
10 |
11 | request.interceptors.request.use(
12 | // 请求成功拦截
13 | (config) => {
14 | if (config.noNeedToken) {
15 | return config
16 | }
17 |
18 | const { token } = useAuthStore()
19 | if (token) {
20 | config.headers.Authorization = `Bearer ${token}`
21 | }
22 | return config
23 | },
24 | // 请求失败拦截
25 | (error) => {
26 | return Promise.reject(error)
27 | },
28 | )
29 |
30 | request.interceptors.response.use(
31 | // 响应成功拦截
32 | (response) => {
33 | // 业务信息
34 | const responseData = response.data
35 | const { code, message, data } = responseData
36 | if (code !== 0) { // ! 与后端约定业务状态码
37 | if (data && message !== data) {
38 | window.$message.error(`${message} ${data}`)
39 | }
40 | else {
41 | window.$message.error(message)
42 | }
43 | console.error(responseData) // 控制台输出错误信息
44 |
45 | const authStore = useAuthStore()
46 | if (code === 1201) { // Token 存在问题
47 | authStore.toLogin()
48 | return
49 | }
50 | // 1202-Token 过期
51 | if (code === 1202 || code === 1203 || code === 1207) {
52 | authStore.forceOffline()
53 | return
54 | }
55 | return Promise.reject(responseData)
56 | }
57 | return Promise.resolve(responseData)
58 | },
59 | // 响应失败拦截
60 | (error) => {
61 | // 主要使用业务状态码决定状态, 一般不根据 HTTP 状态码进行操作
62 | const responseData = error.response?.data
63 | const { message, data } = responseData
64 | if (error.response.status === 500) {
65 | if (message && data) {
66 | window.$message.error(`${message} ${data}`)
67 | }
68 | else {
69 | window.$message.error('服务端异常')
70 | }
71 | }
72 | return Promise.reject(error)
73 | },
74 | )
75 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/utils/index.js:
--------------------------------------------------------------------------------
1 | import { h } from 'vue'
2 | import { Icon } from '@iconify/vue'
3 | import { NIcon } from 'naive-ui'
4 | import dayjs from 'dayjs'
5 |
6 | export * from './http'
7 | export * from './local'
8 | export * from './naiveTool'
9 |
10 | // 相对图片地址 => 完整的图片路径, 用于本地文件上传
11 | // 如果包含 http 说明是 Web 图片资源
12 | // 否则是服务器上的图片,需要拼接服务器路径
13 | const SERVER_URL = import.meta.env.VITE_SERVER_URL
14 | export function convertImgUrl(imgUrl) {
15 | if (!imgUrl) {
16 | return 'http://dummyimage.com/400x400'
17 | }
18 | // 网络资源
19 | if (imgUrl.startsWith('http')) {
20 | return imgUrl
21 | }
22 | return `${SERVER_URL}/${imgUrl}`
23 | }
24 |
25 | /**
26 | * 格式化时间
27 | */
28 | export function formatDate(date = undefined, format = 'YYYY-MM-DD') {
29 | return dayjs(date).format(format)
30 | }
31 |
32 | /**
33 | * 使用 NIcon 渲染图标
34 | */
35 | export function renderIcon(icon, props = { size: 12 }) {
36 | return () => h(NIcon, props, { default: () => h(Icon, { icon }) })
37 | }
38 |
39 | // 前端导出, 传入文件内容和文件名称
40 | export function downloadFile(content, fileName) {
41 | const aEle = document.createElement('a') // 创建下载链接
42 | aEle.download = fileName // 设置下载的名称
43 | aEle.style.display = 'none'// 隐藏的可下载链接
44 | // 字符内容转变成 blob 地址
45 | const blob = new Blob([content])
46 | aEle.href = URL.createObjectURL(blob)
47 | // 绑定点击时间
48 | document.body.appendChild(aEle)
49 | aEle.click()
50 | // 然后移除
51 | document.body.removeChild(aEle)
52 | }
53 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/utils/local.js:
--------------------------------------------------------------------------------
1 | const CryptoSecret = '__SecretKey__'
2 |
3 | /**
4 | * 存储序列化后的数据到 LocalStorage
5 | * @param {string} key
6 | * @param {any} value 对象需要序列化
7 | * @param {number} expire
8 | */
9 | export function setLocal(key, value, expire = 60 * 60 * 24 * 7) {
10 | const data = JSON.stringify({
11 | value,
12 | time: Date.now(),
13 | expire: expire ? new Date().getTime() + expire * 1000 : null,
14 | })
15 | window.localStorage.setItem(key, encrypto(data)) // 加密存储
16 | }
17 |
18 | /**
19 | * 从 LocalStorage 中获取数据, 解密后反序列化, 根据是否过期来返回
20 | * @param {string} key
21 | */
22 | export function getLocal(key) {
23 | const encryptedVal = window.localStorage.getItem(key)
24 | if (encryptedVal) {
25 | const val = decrypto(encryptedVal) // 解密
26 | const { value, expire } = JSON.parse(val)
27 | // 未过期则返回
28 | if (!expire || expire > new Date().getTime()) {
29 | return value
30 | }
31 | }
32 | // 过期则移除
33 | removeLocal(key)
34 | return null
35 | }
36 |
37 | export function removeLocal(key) {
38 | window.localStorage.removeItem(key)
39 | }
40 |
41 | export function clearLocal() {
42 | window.localStorage.clear()
43 | }
44 |
45 | /**
46 | * 加密数据: Base64 加密
47 | * @param {any} data - 数据
48 | */
49 | function encrypto(data) {
50 | const newData = JSON.stringify(data)
51 | const encryptedData = btoa(CryptoSecret + newData)
52 | return encryptedData
53 | }
54 |
55 | /**
56 | * 解密数据: Base64 解密
57 | * @param {string} cipherText - 密文
58 | */
59 | function decrypto(cipherText) {
60 | const decryptedData = atob(cipherText)
61 | const originalText = decryptedData.replace(CryptoSecret, '')
62 | try {
63 | const parsedData = JSON.parse(originalText)
64 | return parsedData
65 | }
66 | catch (error) {
67 | return null
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/article/route.js:
--------------------------------------------------------------------------------
1 | const Layout = () => import('@/layout/index.vue')
2 |
3 | export default {
4 | name: 'Article',
5 | path: '/article',
6 | component: Layout,
7 | redirect: '/article/list',
8 | meta: {
9 | title: '文章管理',
10 | icon: 'ic:twotone-article',
11 | order: 2,
12 | // role: ['admin'],
13 | // requireAuth: true,
14 | },
15 | children: [
16 | {
17 | name: 'ArticleList',
18 | path: 'list',
19 | component: () => import('./list/index.vue'),
20 | meta: {
21 | title: '文章列表',
22 | icon: 'material-symbols:format-list-bulleted',
23 | // role: ['admin'],
24 | // requireAuth: true,
25 | keepAlive: true,
26 | },
27 | },
28 | {
29 | name: 'ArticleWrite',
30 | path: 'write',
31 | component: () => import('./write/index.vue'),
32 | meta: {
33 | title: '发布文章',
34 | icon: 'icon-park-outline:write',
35 | // role: ['admin'],
36 | // requireAuth: true,
37 | keepAlive: true,
38 | },
39 | },
40 | {
41 | name: 'ArticleEdit',
42 | path: 'write/:id',
43 | component: () => import('./write/index.vue'),
44 | isHidden: true,
45 | meta: {
46 | title: '编辑文章',
47 | icon: 'icon-park-outline:write',
48 | // role: ['admin'],
49 | // requireAuth: true,
50 | // keepAlive: true,
51 | },
52 | },
53 | {
54 | name: 'CategoryList',
55 | path: 'category-list',
56 | component: () => import('./category/index.vue'),
57 | meta: {
58 | title: '分类管理',
59 | icon: 'tabler:category',
60 | // role: ['admin'],
61 | // requireAuth: true,
62 | keepAlive: true,
63 | },
64 | },
65 | {
66 | name: 'TagList',
67 | path: 'tag-list',
68 | component: () => import('./tag/index.vue'),
69 | meta: {
70 | title: '标签管理',
71 | icon: 'tabler:tag',
72 | keepAlive: true,
73 | },
74 | },
75 | ],
76 | }
77 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/auth/route.js:
--------------------------------------------------------------------------------
1 | const Layout = () => import('@/layout/index.vue')
2 |
3 | export default {
4 | name: 'Auth',
5 | path: '/auth',
6 | component: Layout,
7 | redirect: '/auth/menu',
8 | meta: {
9 | title: '权限管理',
10 | icon: 'cib:adguard',
11 | order: 3,
12 | // role: ['admin'],
13 | // requireAuth: true,
14 | },
15 | children: [
16 | {
17 | name: 'MenuList',
18 | path: 'menu',
19 | component: () => import('./menu/index.vue'),
20 | meta: {
21 | title: '菜单管理',
22 | icon: 'ic:twotone-menu-book',
23 | keepAlive: true,
24 | },
25 | },
26 | {
27 | name: 'ResourceList',
28 | path: 'resource',
29 | component: () => import('./resource/index.vue'),
30 | meta: {
31 | title: '接口管理',
32 | icon: 'mdi:api',
33 | keepAlive: true,
34 | },
35 | },
36 | {
37 | name: 'RoleList',
38 | path: 'role',
39 | component: () => import('./role/index.vue'),
40 | meta: {
41 | title: '角色管理',
42 | icon: 'carbon:user-role',
43 | keepAlive: true,
44 | },
45 | },
46 | ],
47 | }
48 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/error-page/404.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | 返回首页
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/error-page/route.js:
--------------------------------------------------------------------------------
1 | const Layout = () => import('@/layout/index.vue')
2 |
3 | export default {
4 | name: 'ErrorPage',
5 | path: '/error-page',
6 | component: Layout,
7 | redirect: '/error-page/404',
8 | isHidden: true,
9 | meta: {
10 | title: '错误页',
11 | icon: 'mdi:alert-circle-outline',
12 | order: 99,
13 | },
14 | children: [
15 | {
16 | name: 'ERROR-404',
17 | path: '404',
18 | component: () => import('./404.vue'),
19 | meta: {
20 | title: '404',
21 | icon: 'tabler:error-404',
22 | },
23 | },
24 | ],
25 | }
26 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/home/route.js:
--------------------------------------------------------------------------------
1 | const Layout = () => import('@/layout/index.vue')
2 |
3 | export default {
4 | name: 'Home',
5 | path: '/',
6 | component: Layout,
7 | redirect: '/home',
8 | meta: {
9 | order: 0,
10 | },
11 | isCatalogue: true,
12 | children: [
13 | {
14 | name: 'Home',
15 | path: 'home',
16 | component: () => import('./index.vue'),
17 | meta: {
18 | title: '首页',
19 | icon: 'ic:sharp-home',
20 | order: 0,
21 | },
22 | },
23 | ],
24 | }
25 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/log/login/index.vue:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/log/route.js:
--------------------------------------------------------------------------------
1 | const Layout = () => import('@/layout/index.vue')
2 |
3 | export default {
4 | name: 'Log',
5 | path: '/log',
6 | component: Layout,
7 | redirect: '/log/operation',
8 | meta: {
9 | title: '操作日志',
10 | icon: 'mdi:math-log',
11 | order: 6,
12 | },
13 | children: [
14 | {
15 | name: 'OperatingLog',
16 | path: 'operation',
17 | component: () => import('./operation/index.vue'),
18 | meta: {
19 | title: '操作日志',
20 | icon: 'mdi:book-open-page-variant-outline',
21 | keepAlive: true,
22 | },
23 | },
24 | {
25 | name: 'LoginLog',
26 | path: 'login',
27 | component: () => import('./login/index.vue'),
28 | meta: {
29 | title: '登录日志',
30 | icon: 'material-symbols:login',
31 | keepAlive: true,
32 | },
33 | },
34 | ],
35 | }
36 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/message/route.js:
--------------------------------------------------------------------------------
1 | const Layout = () => import('@/layout/index.vue')
2 |
3 | export default {
4 | name: 'Message',
5 | path: '/message',
6 | component: Layout,
7 | redirect: '/message/comment',
8 | meta: {
9 | title: '消息管理',
10 | icon: 'ic:twotone-email',
11 | order: 3,
12 | // role: ['admin'],
13 | // requireAuth: true,
14 | },
15 | children: [
16 | {
17 | name: 'CommentList',
18 | path: 'comment',
19 | component: () => import('./comment/index.vue'),
20 | meta: {
21 | title: '评论管理',
22 | icon: 'ic:twotone-comment',
23 | keepAlive: true,
24 | },
25 | },
26 | {
27 | name: 'LeaveMsgList',
28 | path: 'leave-msg',
29 | component: () => import('./leave-msg/index.vue'),
30 | meta: {
31 | title: '留言管理',
32 | icon: 'ic:twotone-message',
33 | keepAlive: true,
34 | },
35 | },
36 | ],
37 | }
38 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/profile/route.js:
--------------------------------------------------------------------------------
1 | const Layout = () => import('@/layout/index.vue')
2 |
3 | export default {
4 | name: 'Profile',
5 | path: '/',
6 | component: Layout,
7 | redirect: '/profile',
8 | meta: {
9 | order: 8,
10 | },
11 | isCatalogue: true,
12 | children: [
13 | {
14 | name: 'Profile',
15 | path: '/profile',
16 | component: () => import('./index.vue'),
17 | meta: {
18 | title: '个人中心',
19 | icon: 'mdi:account',
20 | order: 0,
21 | },
22 | },
23 | ],
24 | }
25 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/setting/about/index.vue:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
35 |
36 |
37 | 关于我
38 |
39 |
40 |
41 |
42 |
43 | 保存
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
59 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/setting/route.js:
--------------------------------------------------------------------------------
1 | const Layout = () => import('@/layout/index.vue')
2 |
3 | export default {
4 | name: 'System',
5 | path: '/system',
6 | component: Layout,
7 | redirect: '/system/website',
8 | meta: {
9 | title: '系统管理',
10 | icon: 'ion:md-settings',
11 | order: 6,
12 | // role: ['admin'],
13 | // requireAuth: true,
14 | },
15 | children: [
16 | {
17 | name: 'Website',
18 | path: 'website',
19 | component: () => import('./website/index.vue'),
20 | meta: {
21 | title: '网站管理',
22 | icon: 'el:website',
23 | order: 1,
24 | keepAlive: true,
25 | },
26 | },
27 | {
28 | name: '页面管理',
29 | path: 'page',
30 | component: () => import('./page/index.vue'),
31 | meta: {
32 | title: '页面管理',
33 | icon: 'iconoir:journal-page',
34 | order: 2,
35 | keepAlive: true,
36 | },
37 | },
38 | {
39 | name: 'FriendLink',
40 | path: 'link',
41 | component: () => import('./link/index.vue'),
42 | meta: {
43 | title: '友链管理',
44 | icon: 'mdi:telegram',
45 | order: 3,
46 | keepAlive: true,
47 | },
48 | },
49 | {
50 | name: 'About',
51 | path: 'about',
52 | component: () => import('./about/index.vue'),
53 | meta: {
54 | title: '关于我',
55 | icon: 'cib:about-me',
56 | order: 4,
57 | keepAlive: true,
58 | },
59 | },
60 | ],
61 | }
62 |
--------------------------------------------------------------------------------
/gin-blog-admin/src/views/user/route.js:
--------------------------------------------------------------------------------
1 | const Layout = () => import('@/layout/index.vue')
2 |
3 | export default {
4 | name: 'User',
5 | path: '/user',
6 | component: Layout,
7 | redirect: '/user/list',
8 | meta: {
9 | title: '用户管理',
10 | icon: 'ph:user-list-bold',
11 | order: 5,
12 | // role: ['admin'],
13 | // requireAuth: true,
14 | },
15 | children: [
16 | {
17 | name: 'UserList',
18 | path: 'list',
19 | component: () => import('./list/index.vue'),
20 | meta: {
21 | title: '用户列表',
22 | icon: 'mdi:account',
23 | keepAlive: true,
24 | },
25 | },
26 | {
27 | name: 'OnlineUserList',
28 | path: 'online',
29 | component: () => import('./online/index.vue'),
30 | meta: {
31 | title: '在线用户',
32 | icon: 'ic:outline-online-prediction',
33 | keepAlive: true,
34 | },
35 | },
36 | ],
37 | }
38 |
--------------------------------------------------------------------------------
/gin-blog-admin/uno.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig, presetIcons, presetUno } from 'unocss'
2 |
3 | export default defineConfig({
4 | presets: [
5 | presetUno(),
6 | presetIcons({
7 | prefix: ['i-'],
8 | scale: 1.2,
9 | extraProperties: {
10 | 'display': 'inline-block',
11 | 'vertical-align': 'middle',
12 | },
13 | }),
14 | ],
15 | theme: {
16 | colors: {
17 | primary: 'var(--primary-color)',
18 | primary_hover: 'var(--primary-color-hover)',
19 | primary_pressed: 'var(--primary-color-pressed)',
20 | primary_active: 'var(--primary-color-active)',
21 | info: 'var(--info-color)',
22 | info_hover: 'var(--info-color-hover)',
23 | info_pressed: 'var(--info-color-pressed)',
24 | info_active: 'var(--info-color-active)',
25 | success: 'var(--success-color)',
26 | success_hover: 'var(--success-color-hover)',
27 | success_pressed: 'var(--success-color-pressed)',
28 | success_active: 'var(--success-color-active)',
29 | warning: 'var(--warning-color)',
30 | warning_hover: 'var(--warning-color-hover)',
31 | warning_pressed: 'var(--warning-color-pressed)',
32 | warning_active: 'var(--warning-color-active)',
33 | error: 'var(--error-color)',
34 | error_hover: 'var(--error-color-hover)',
35 | error_pressed: 'var(--error-color-pressed)',
36 | error_active: 'var(--error-color-active)',
37 | dark: '#18181c',
38 | },
39 | },
40 | })
41 |
--------------------------------------------------------------------------------
/gin-blog-admin/vite.config.js:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import { defineConfig, loadEnv } from 'vite'
3 | import vue from '@vitejs/plugin-vue'
4 | import unocss from 'unocss/vite'
5 | import viteCompression from 'vite-plugin-compression'
6 | import { visualizer } from 'rollup-plugin-visualizer'
7 |
8 | export default defineConfig((configEnv) => {
9 | const env = loadEnv(configEnv.mode, process.cwd())
10 |
11 | return {
12 | base: env.VITE_PUBLIC_PATH || '/',
13 | resolve: {
14 | alias: {
15 | '@': path.resolve(process.cwd(), 'src'),
16 | '~': path.resolve(process.cwd()),
17 | },
18 | },
19 | plugins: [
20 | vue(),
21 | unocss(),
22 | viteCompression({ algorithm: 'gzip' }),
23 | visualizer({ open: false, gzipSize: true, brotliSize: true }),
24 | ],
25 | server: {
26 | host: '0.0.0.0',
27 | port: 3000,
28 | open: false,
29 | proxy: {
30 | '/api': {
31 | target: env.VITE_SERVER_URL,
32 | changeOrigin: true,
33 | },
34 | },
35 | },
36 | // https://cn.vitejs.dev/guide/api-javascript.html#build
37 | build: {
38 | chunkSizeWarningLimit: 1024, // chunk 大小警告的限制(单位kb)
39 | },
40 | esbuild: {
41 | drop: ['debugger'], // console
42 | },
43 | }
44 | })
45 |
--------------------------------------------------------------------------------
/gin-blog-front/.dockerignore:
--------------------------------------------------------------------------------
1 | node_module
2 |
--------------------------------------------------------------------------------
/gin-blog-front/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://editorconfig.org/
2 | # 当前是根目录, 无需再往上层寻找
3 | root = true
4 |
5 | [*] # 匹配所有的文件
6 | charset = utf-8 # 文件编码
7 | indent_style = space # 空格缩进
8 | indent_size = 2 # 缩进空格为 2
9 | end_of_line = lf # 文件换行符是 Linux 的 '\n'
10 | trim_trailing_whitespace = true # 不保留行末的空格
11 | insert_final_newline = true # 文件末尾添加一个空行
12 | spaces_around_operators = true # 运算符两遍都有空格
13 | # max_line_length = 100
14 |
15 | [*.md] # 只对 .md 文件生效
16 | trim_trailing_whitespace = false
17 |
--------------------------------------------------------------------------------
/gin-blog-front/.env:
--------------------------------------------------------------------------------
1 | VITE_APP_TITLE = '阵、雨'
2 |
--------------------------------------------------------------------------------
/gin-blog-front/.env.development:
--------------------------------------------------------------------------------
1 | # 资源公共路径,需要以 /开头和结尾
2 | VITE_PUBLIC_PATH = '/'
3 |
4 | # 基础 API: 设置代理就写代理 API, 否则写完整 URL
5 | VITE_API = '/api'
6 |
7 | # 后端 URL
8 | VITE_BACKEND_URL = 'http://localhost:8765'
9 |
10 | # 登录时是否需要验证码
11 | VITE_USE_CAPTCHA = false
12 |
--------------------------------------------------------------------------------
/gin-blog-front/.env.production:
--------------------------------------------------------------------------------
1 | # 资源公共路径,需要以 /开头和结尾
2 | VITE_PUBLIC_PATH = '/'
3 |
4 | # 基础 API: 设置代理就写代理 API, 否则写完整 URL
5 | VITE_API = '/api'
6 |
7 | # 后端 URL
8 | VITE_BACKEND_URL = 'http://localhost:8765'
9 |
10 | # 登录时是否需要验证码
11 | VITE_USE_CAPTCHA = false
12 |
--------------------------------------------------------------------------------
/gin-blog-front/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | # Dependency directories
11 | node_modules/
12 | jspm_packages/
13 |
14 | # Build
15 | dist
16 | dist-ssr
17 | *.local
18 |
19 | # MacOS
20 | .DS_Store
21 |
22 | # Vim swap files
23 | *.swp
24 |
25 | # Editor directories and files
26 | .idea
27 | *.suo
28 | *.ntvs*
29 | *.njsproj
30 | *.sln
31 | *.sw?
32 |
33 | # rollup-plugin-visualizer
34 | stats.html
35 |
36 | # Dotenv
37 | *.local
38 | !.env.sample
39 |
--------------------------------------------------------------------------------
/gin-blog-front/.npmrc:
--------------------------------------------------------------------------------
1 | shamefully-hoist=true
2 | strict-peer-dependencies=false
3 | registry = https://registry.npmmirror.com
4 |
--------------------------------------------------------------------------------
/gin-blog-front/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "Vue.volar",
4 | "antfu.iconify",
5 | "antfu.unocss",
6 | "dbaeumer.vscode-eslint",
7 | "EditorConfig.EditorConfig"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/gin-blog-front/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | // Enable the ESlint flat config support
3 | "eslint.experimental.useFlatConfig": true,
4 |
5 | // Disable the default formatter, use eslint instead
6 | "prettier.enable": false,
7 | "editor.formatOnSave": false,
8 |
9 | // Auto fix
10 | "editor.codeActionsOnSave": {
11 | "source.fixAll.eslint": "explicit",
12 | "source.organizeImports": "never"
13 | },
14 |
15 | // Silent the stylistic rules in you IDE, but still auto fix them
16 | "eslint.rules.customizations": [
17 | { "rule": "style/*", "severity": "off" },
18 | { "rule": "format/*", "severity": "off" },
19 | { "rule": "*-indent", "severity": "off" },
20 | { "rule": "*-spacing", "severity": "off" },
21 | { "rule": "*-spaces", "severity": "off" },
22 | { "rule": "*-order", "severity": "off" },
23 | { "rule": "*-dangle", "severity": "off" },
24 | { "rule": "*-newline", "severity": "off" },
25 | { "rule": "*quotes", "severity": "off" },
26 | { "rule": "*semi", "severity": "off" }
27 | ],
28 |
29 | // Enable eslint for all supported languages
30 | "eslint.validate": [
31 | "javascript",
32 | "javascriptreact",
33 | "typescript",
34 | "typescriptreact",
35 | "vue",
36 | "html",
37 | "markdown",
38 | "json",
39 | "jsonc",
40 | "yaml"
41 | ]
42 | }
43 |
--------------------------------------------------------------------------------
/gin-blog-front/README.md:
--------------------------------------------------------------------------------
1 | ## 博客设计
2 |
3 | 响应式:移动优先,大屏适应
4 |
5 | ## TODO
6 |
7 | - 不使用 NaiveUI 作为组件库,使用自研组件库 ✅
8 |
--------------------------------------------------------------------------------
/gin-blog-front/eslint.config.js:
--------------------------------------------------------------------------------
1 | import antfu from '@antfu/eslint-config'
2 |
3 | export default antfu({
4 | unocss: true,
5 | rules: {
6 | 'no-console': 'warn',
7 | 'curly': 'off',
8 | '@typescript-eslint/brace-style': 'off',
9 | 'node/prefer-global/process': 'off',
10 | 'unused-imports/no-unused-imports': 'off',
11 | },
12 | })
13 |
--------------------------------------------------------------------------------
/gin-blog-front/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | 阵、雨的博客
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/gin-blog-front/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "ESNext",
5 | "moduleResolution": "node",
6 | "strict": true,
7 | "jsx": "preserve",
8 | "paths": {
9 | "@/*": ["./src/*"],
10 | "~/*": ["./*"]
11 | }
12 | },
13 | "exclude": ["dist", "node_modules"]
14 | }
15 |
--------------------------------------------------------------------------------
/gin-blog-front/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "module",
3 | "private": true,
4 | "scripts": {
5 | "dev": "vite",
6 | "lint": "eslint .",
7 | "build": "vite build",
8 | "preview": "vite preview",
9 | "up": "taze major -I"
10 | },
11 | "dependencies": {
12 | "@vueuse/core": "^10.7.0",
13 | "axios": "^1.6.2",
14 | "dayjs": "^1.11.10",
15 | "highlight.js": "^11.9.0",
16 | "lodash-es": "^4.17.21",
17 | "marked": "^11.0.1",
18 | "pinia": "^2.1.7",
19 | "pinia-plugin-persistedstate": "^3.2.0",
20 | "v3-infinite-loading": "^1.3.1",
21 | "vue": "^3.3.11",
22 | "vue-router": "^4.2.5",
23 | "vue3-danmaku": "^1.6.0"
24 | },
25 | "devDependencies": {
26 | "@antfu/eslint-config": "^2.4.4",
27 | "@iconify/json": "^2.2.155",
28 | "@iconify/vue": "^4.1.1",
29 | "@unocss/eslint-config": "^0.58.0",
30 | "@unocss/reset": "^0.45.30",
31 | "@vitejs/plugin-vue": "^3.2.0",
32 | "easy-typer-js": "^2.1.0",
33 | "eslint": "^8.55.0",
34 | "nprogress": "^0.2.0",
35 | "rollup-plugin-visualizer": "^5.11.0",
36 | "sass": "^1.69.5",
37 | "taze": "^0.8.5",
38 | "unocss": "^0.58.0",
39 | "vite": "^3.2.7",
40 | "vite-plugin-compression": "^0.5.1"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/gin-blog-front/public/cursor/handwriting.cur:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szluyu99/gin-vue-blog/61dd11ccd296e8642a318ada3ef7b3f7776d2410/gin-blog-front/public/cursor/handwriting.cur
--------------------------------------------------------------------------------
/gin-blog-front/public/cursor/link.cur:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szluyu99/gin-vue-blog/61dd11ccd296e8642a318ada3ef7b3f7776d2410/gin-blog-front/public/cursor/link.cur
--------------------------------------------------------------------------------
/gin-blog-front/public/cursor/normal.cur:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szluyu99/gin-vue-blog/61dd11ccd296e8642a318ada3ef7b3f7776d2410/gin-blog-front/public/cursor/normal.cur
--------------------------------------------------------------------------------
/gin-blog-front/public/js/mathjax.js:
--------------------------------------------------------------------------------
1 | window.MathJax = {
2 | tex: {
3 | // 行内公式选择符
4 | inlineMath: [
5 | ['$', '$'],
6 | ['\\(', '\\)'],
7 | ],
8 | // 段内公式选择符
9 | displayMath: [
10 | ['$$', '$$'],
11 | ['\\[', '\\]'],
12 | ],
13 | },
14 | startup: {
15 | ready() {
16 | MathJax.startup.defaultReady()
17 | },
18 | },
19 | }
20 |
--------------------------------------------------------------------------------
/gin-blog-front/src/App.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/gin-blog-front/src/api.js:
--------------------------------------------------------------------------------
1 | import { baseRequest, request } from '@/utils/http'
2 |
3 | export default {
4 | login: (data = {}) => baseRequest.post('/login', data),
5 | register: (data = {}) => baseRequest.post('/register', data),
6 | logout: () => baseRequest.get('/logout'),
7 | /** 发送验证码 */
8 | sendCode: params => baseRequest.get('/code', { params }),
9 |
10 | /** 关于我 */
11 | about: () => request.get('/about'),
12 | /** 获取页面 */
13 | getPageList: () => request.get('/page'),
14 | /** 首页数据 */
15 | getHomeData: () => request.get('/home'),
16 | /** 首页文章列表 */
17 | getArticles: params => request.get('/article/list', { params }),
18 | /** 文章详情 */
19 | getArticleDetail: id => request.get(`/article/${id}`),
20 | /** 文章归档 */
21 | getArchives: (params = {}) => request.get('/article/archive', { params }),
22 | /** 文章搜索 */
23 | searchArticles: (params = {}) => request.get('/article/search', { params }),
24 |
25 | /** 菜单列表 */
26 | getCategorys: () => request.get('/category/list'),
27 | /** 标签列表 */
28 | getTags: () => request.get('/tag/list'),
29 | /** 留言列表 */
30 | getMessages: () => request.get('/message/list'),
31 | /** 友链列表 */
32 | getLinks: () => request.get('/link/list'),
33 | /** 评论列表 */
34 | getComments: (params = {}) => request.get('/comment/list', { params }),
35 | /** 评论回复列表 */
36 | getCommentReplies: (id, params = {}) => request.get(`/comment/replies/${id}`, { params }),
37 |
38 | // ! 需要 Token 的接口
39 | /** 根据 token 获取当前用户信息 */
40 | getUser: () => request.get('/user/info', { needToken: true }),
41 | /** 修改当前用户信息 */
42 | updateUser: data => request.put('/user/info', data, { needToken: true }),
43 | /** 留言 */
44 | saveMessage: data => request.post('/message', data, { needToken: true }),
45 | /** 评论 */
46 | saveComment: data => request.post('/comment', data, { needToken: true }),
47 | /** 点赞评论 */
48 | saveLikeComment: id => request.get(`/comment/like/${id}`, { needToken: true }),
49 | /** 点赞文章 */
50 | saveLikeArticle: id => request.get(`/article/like/${id}`, { needToken: true }),
51 | }
52 |
--------------------------------------------------------------------------------
/gin-blog-front/src/assets/config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | QQ_APP_ID: '101878726',
3 | QQ_REDIRECT_URI: 'https://www.talkxj.com/oauth/login/qq',
4 | WEIBO_APP_ID: '4039197195',
5 | WEIBO_REDIRECT_URI: 'https://www.talkxj.com/oauth/login/weibo',
6 | TENCENT_CAPTCHA: '2088053498',
7 | }
8 |
--------------------------------------------------------------------------------
/gin-blog-front/src/components/BackTop.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
37 |
38 |
--------------------------------------------------------------------------------
/gin-blog-front/src/components/BannerPage.vue:
--------------------------------------------------------------------------------
1 |
56 |
57 |
58 |
59 |
60 |
61 | {{ props.title }}
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
--------------------------------------------------------------------------------
/gin-blog-front/src/components/comment/Paging.vue:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 |
46 |
47 |
共 {{ pageTotal }} 页
48 |
49 |
上一页
50 |
51 |
52 |
53 |
54 |
55 |
56 | {{ i }}
57 |
58 | {{ i }}
59 |
60 |
61 |
62 |
63 |
64 |
65 |
下一页
66 |
67 |
68 |
69 |
75 |
--------------------------------------------------------------------------------
/gin-blog-front/src/components/layout/AppFooter.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
17 |
18 |
19 |
38 |
--------------------------------------------------------------------------------
/gin-blog-front/src/components/modal/index.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/gin-blog-front/src/components/ui/UButton.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
23 |
24 |
25 |
26 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/gin-blog-front/src/components/ui/UDrawer.vue:
--------------------------------------------------------------------------------
1 |
59 |
60 |
61 |
62 |
63 |
66 |
84 |
85 |
86 |
87 |
88 |
--------------------------------------------------------------------------------
/gin-blog-front/src/components/ui/ULoading.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/gin-blog-front/src/components/ui/USpin.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 |
30 |
31 |
40 | loading
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/gin-blog-front/src/main.js:
--------------------------------------------------------------------------------
1 | // custom style
2 | import './styles/index.css'
3 | import './styles/common.css'
4 | import './styles/animate.css'
5 |
6 | // unocss
7 | import 'uno.css'
8 | import '@unocss/reset/tailwind.css'
9 |
10 | // vue
11 | import { createApp } from 'vue'
12 |
13 | import { router } from './router'
14 | import { pinia } from './store'
15 | import App from './App.vue'
16 |
17 | const app = createApp(App)
18 | app.use(router)
19 | app.use(pinia)
20 | app.mount('#app')
21 |
--------------------------------------------------------------------------------
/gin-blog-front/src/store/app.js:
--------------------------------------------------------------------------------
1 | import { defineStore } from 'pinia'
2 | import { convertImgUrl } from '@/utils'
3 | import api from '@/api'
4 |
5 | export const useAppStore = defineStore('app', {
6 | state: () => ({
7 | searchFlag: false,
8 | loginFlag: false,
9 | registerFlag: false,
10 | collapsed: false, // 侧边栏折叠(移动端)
11 |
12 | page_list: [], // 页面数据
13 | // TODO: 优化
14 | blogInfo: {
15 | article_count: 0,
16 | category_count: 0,
17 | tag_count: 0,
18 | view_count: 0,
19 | user_count: 0,
20 | },
21 | blog_config: {
22 | website_name: '阵、雨的个人博客',
23 | website_author: '阵、雨',
24 | website_intro: '往事随风而去',
25 | website_avatar: '',
26 | },
27 | }),
28 | getters: {
29 | isMobile: () => !!navigator.userAgent.match(/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i),
30 | articleCount: state => state.blogInfo.article_count ?? 0,
31 | categoryCount: state => state.blogInfo.category_count ?? 0,
32 | tagCount: state => state.blogInfo.tag_count ?? 0,
33 | viewCount: state => state.blogInfo.view_count ?? 0,
34 | pageList: state => state.page_list ?? [],
35 | blogConfig: state => state.blog_config,
36 | },
37 | actions: {
38 | setCollapsed(flag) { this.collapsed = flag },
39 | setLoginFlag(flag) { this.loginFlag = flag },
40 | setRegisterFlag(flag) { this.registerFlag = flag },
41 | setSearchFlag(flag) { this.searchFlag = flag },
42 |
43 | async getBlogInfo() {
44 | try {
45 | const resp = await api.getHomeData()
46 | if (resp.code === 0) {
47 | this.blogInfo = resp.data
48 | this.blog_config = resp.data.blog_config
49 | this.blog_config.website_avatar = convertImgUrl(this.blog_config.website_avatar)
50 | }
51 | else {
52 | return Promise.reject(resp)
53 | }
54 | }
55 | catch (err) {
56 | return Promise.reject(err)
57 | }
58 | },
59 |
60 | async getPageList() {
61 | const resp = await api.getPageList()
62 | if (resp.code === 0) {
63 | this.page_list = resp.data
64 | this.page_list?.forEach(e => (e.cover = convertImgUrl(e.cover)))
65 | }
66 | },
67 | },
68 | })
69 |
--------------------------------------------------------------------------------
/gin-blog-front/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { createPinia } from 'pinia'
2 |
3 | // https://github.com/prazdevs/pinia-plugin-persistedstate
4 | // pinia 数据持久化,解决刷新数据丢失的问题
5 | import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
6 |
7 | export const pinia = createPinia()
8 | pinia.use(piniaPluginPersistedstate)
9 |
10 | export * from './app'
11 | export * from './user'
12 |
--------------------------------------------------------------------------------
/gin-blog-front/src/styles/animate.css:
--------------------------------------------------------------------------------
1 | @charset "UTF-8";
2 |
3 | /* Transition 动画 */
4 | .slide-fade-enter-active {
5 | transition: all 0.5s ease-out;
6 | }
7 |
8 | .slide-fade-leave-active {
9 | transition: all 0.5s cubic-bezier(1, 0.5, 0.8, 1);
10 | }
11 |
12 | .slide-fade-enter-from,
13 | .slide-fade-leave-to {
14 | transform: translateY(-50px);
15 | opacity: 0;
16 | }
17 |
18 | /* 光标闪烁动画 (目前使用 animate-ping 替代) */
19 | .type-cursor {
20 | opacity: 1;
21 | animation: blink-anim 0.7s infinite;
22 | }
23 |
24 | @keyframes blink-anim {
25 | 0% {
26 | opacity: 1;
27 | }
28 | 50% {
29 | opacity: 0;
30 | }
31 | 100% {
32 | opacity: 1;
33 | }
34 | }
35 |
36 | /* 图片下降动画 */
37 | .banner-fade-down {
38 | animation: banner-fade-down-anim 1s;
39 | }
40 |
41 | @keyframes banner-fade-down-anim {
42 | 0% {
43 | opacity: 0;
44 | filter: alpha(opacity=0);
45 | transform: translateY(-50px);
46 | }
47 | 100% {
48 | opacity: 1;
49 | filter: none;
50 | transform: translateY(0);
51 | }
52 | }
53 |
54 | /* 图片上升动画 */
55 | .card-fade-up {
56 | animation: card-fade-up-anim 0.6s;
57 | }
58 |
59 | @keyframes card-fade-up-anim {
60 | 0% {
61 | opacity: 0;
62 | filter: alpha(opacity=0);
63 | transform: translateY(50px);
64 | }
65 | 100% {
66 | opacity: 1;
67 | filter: none;
68 | transform: translateY(0);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/gin-blog-front/src/styles/common.css:
--------------------------------------------------------------------------------
1 | /* 通用按钮 */
2 | .the-button {
3 | @apply px-4 py-1 rounded-lg inline-block bg-[#49b1f5] text-white cursor-pointer duration-200;
4 | @apply hover:bg-[#ff7242] disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50;
5 | }
6 |
7 | /* 卡片面板 */
8 | .card-view {
9 | @apply bg-white p-3 rounded-xl transition-500 hover:shadow-2xl;
10 | }
11 |
--------------------------------------------------------------------------------
/gin-blog-front/src/styles/index.css:
--------------------------------------------------------------------------------
1 | @charset "UTF-8";
2 |
3 | /* Inter Font */
4 | :root {
5 | font-family: Inter, sans-serif;
6 | font-feature-settings: 'liga' 1, 'calt' 1; /* fix for Chrome */
7 | }
8 |
9 | @supports (font-variation-settings: normal) {
10 | :root { font-family: InterVariable, sans-serif; }
11 | }
12 |
13 | html, body, #app {
14 | width: 100%;
15 | height: 100%;
16 | margin: 0;
17 | padding: 0;
18 | }
19 |
20 | body {
21 | /* 根路径加载静态资源, 让每个路由下都能加载到 */
22 | cursor: url("/cursor/normal.cur"), auto;
23 |
24 | width: 100%;
25 | height: 100%;
26 | overflow-y: scroll;
27 | background: linear-gradient(90deg, rgba(247, 149, 51, 0.1) 0, rgba(243, 112, 85, 0.1) 15%, rgba(239, 78, 123, 0.1) 30%, rgba(161, 102, 171, 0.1) 44%, rgba(80, 115, 184, 0.1) 58%, rgba(16, 152, 173, 0.1) 72%, rgba(7, 179, 155, 0.1) 86%, rgba(109, 186, 130, 0.1) 100%);
28 |
29 | /* font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Lato, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif !important; */
30 |
31 | /* 无法选中文字 */
32 | /* user-select: none;
33 | -moz-user-select: none;
34 | -webkit-user-select: none; */
35 | }
36 |
37 | /* 设置鼠标图标 */
38 | a,
39 | button,
40 | img {
41 | cursor: url("/cursor/link.cur"), auto;
42 | }
43 |
44 | /* 自定义滚动条样式 */
45 | ::-webkit-scrollbar {
46 | width: 8px;
47 | height: 8px;
48 | }
49 |
50 | ::-webkit-scrollbar-track {
51 | background-color: rgba(73, 177, 245, 0.2);
52 | border-radius: 2em;
53 | }
54 |
55 | ::-webkit-scrollbar-thumb {
56 | background-color: #49b1f5;
57 | background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.4) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.4) 75%, transparent 75%, transparent);
58 | border-radius: 2em;
59 | }
60 |
61 | ::-webkit-scrollbar-corner {
62 | background-color: transparent;
63 | }
64 |
65 | ::-moz-selection {
66 | color: #fff;
67 | background-color: #49b1f5;
68 | }
69 |
--------------------------------------------------------------------------------
/gin-blog-front/src/styles/nprogress.css:
--------------------------------------------------------------------------------
1 | /* Make clicks pass-through */
2 | #nprogress {
3 | pointer-events: none;
4 | }
5 |
6 | #nprogress .bar {
7 | background: #29d;
8 |
9 | position: fixed;
10 | z-index: 1031;
11 | top: 0;
12 | left: 0;
13 |
14 | width: 100%;
15 | height: 2.5px;
16 | }
17 |
18 | /* Fancy blur effect */
19 | #nprogress .peg {
20 | display: block;
21 | position: absolute;
22 | right: 0px;
23 | width: 100px;
24 | height: 100%;
25 | /* box-shadow: 0 0 10px #29d, 0 0 5px #29d; */
26 | opacity: 1.0;
27 |
28 | -moz-box-shadow:0px 10px 20px #29d; -webkit-box-shadow:0px 10px 20px #29d; box-shadow:0px 10px 20px #29d;
29 |
30 | -webkit-transform: rotate(3deg) translate(0px, -4px);
31 | -ms-transform: rotate(3deg) translate(0px, -4px);
32 | transform: rotate(3deg) translate(0px, -4px);
33 | }
34 |
35 | /* Remove these to get rid of the spinner */
36 | #nprogress .spinner {
37 | display: block;
38 | position: fixed;
39 | z-index: 1031;
40 | top: 15px;
41 | right: 15px;
42 | }
43 |
44 | #nprogress .spinner-icon {
45 | width: 18px;
46 | height: 18px;
47 | box-sizing: border-box;
48 |
49 | border: solid 2px transparent;
50 | border-top-color: #29d;
51 | border-left-color: #29d;
52 | border-radius: 50%;
53 |
54 | -webkit-animation: nprogress-spinner 400ms linear infinite;
55 | animation: nprogress-spinner 400ms linear infinite;
56 | }
57 |
58 | .nprogress-custom-parent {
59 | overflow: hidden;
60 | position: relative;
61 | }
62 |
63 | .nprogress-custom-parent #nprogress .spinner,
64 | .nprogress-custom-parent #nprogress .bar {
65 | position: absolute;
66 | }
67 |
68 | @-webkit-keyframes nprogress-spinner {
69 | 0% { -webkit-transform: rotate(0deg); }
70 | 100% { -webkit-transform: rotate(360deg); }
71 | }
72 | @keyframes nprogress-spinner {
73 | 0% { transform: rotate(0deg); }
74 | 100% { transform: rotate(360deg); }
75 | }
76 |
--------------------------------------------------------------------------------
/gin-blog-front/src/utils/http.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { useAppStore, useUserStore } from '@/store'
3 |
4 | // 通用请求
5 | export const baseRequest = axios.create(
6 | {
7 | baseURL: import.meta.env.VITE_API,
8 | timeout: 12000,
9 | },
10 | )
11 |
12 | baseRequest.interceptors.request.use(requestSuccess, requestFail)
13 | baseRequest.interceptors.response.use(responseSuccess, responseFail)
14 |
15 | // 前台请求
16 | export const request = axios.create(
17 | {
18 | baseURL: `${import.meta.env.VITE_API}/front`,
19 | timeout: 12000,
20 | },
21 | )
22 |
23 | request.interceptors.request.use(requestSuccess, requestFail)
24 | request.interceptors.response.use(responseSuccess, responseFail)
25 |
26 | /**
27 | * 请求成功拦截
28 | * @param {import('axios').InternalAxiosRequestConfig} config
29 | */
30 | function requestSuccess(config) {
31 | if (config.needToken) {
32 | const { token } = useUserStore()
33 | if (!token) {
34 | return Promise.reject(new axios.AxiosError('当前没有登录,请先登录!', 401))
35 | }
36 | config.headers.Authorization = config.headers.Authorization || `Bearer ${token}`
37 | }
38 | return config
39 | }
40 |
41 | /**
42 | * 请求失败拦截
43 | * @param {any} error
44 | */
45 | function requestFail(error) {
46 | return Promise.reject(error)
47 | }
48 |
49 | /**
50 | * 响应成功拦截
51 | * @param {import('axios').AxiosResponse} response
52 | */
53 | function responseSuccess(response) {
54 | const responseData = response.data
55 | const { code, message } = responseData
56 | if (code !== 0) { // 与后端约定业务状态码
57 | if (code === 1203) {
58 | // 移除 token
59 | const userStore = useUserStore()
60 | userStore.resetLoginState()
61 | }
62 | window.$message.error(message)
63 | return Promise.reject(responseData)
64 | }
65 | return Promise.resolve(responseData)
66 | }
67 |
68 | /**
69 | * 响应失败拦截
70 | * @param {any} error
71 | */
72 | function responseFail(error) {
73 | const { code, message } = error
74 | if (code === 401) {
75 | window.$message.error(message)
76 | // 移除 token
77 | const userStore = useUserStore()
78 | userStore.resetLoginState()
79 | // 登录弹框
80 | const appStore = useAppStore()
81 | appStore.setLoginFlag(true)
82 | }
83 | return Promise.reject(error)
84 | }
85 |
--------------------------------------------------------------------------------
/gin-blog-front/src/utils/index.js:
--------------------------------------------------------------------------------
1 | // 相对图片地址 => 完整的图片路径, 用于本地文件上传
2 | // - 如果包含 http 说明是 Web 图片资源
3 | // - 否则是服务器上的图片,需要拼接服务器路径
4 | const SERVER_URL = import.meta.env.VITE_BACKEND_URL
5 |
6 | /**
7 | * 将相对地址转换为完整的图片路径
8 | * @param {string} imgUrl
9 | * @returns {string} 完整的图片路径
10 | */
11 | export function convertImgUrl(imgUrl) {
12 | if (!imgUrl) {
13 | return 'http://dummyimage.com/400x400'
14 | }
15 | // 网络资源
16 | if (imgUrl.startsWith('http')) {
17 | return imgUrl
18 | }
19 | // 服务器资源
20 | return `${SERVER_URL}/${imgUrl}`
21 | }
22 |
23 | export * from './local'
24 | export * from './http'
25 |
--------------------------------------------------------------------------------
/gin-blog-front/src/utils/local.js:
--------------------------------------------------------------------------------
1 | const CryptoSecret = '__SecretKey__'
2 |
3 | /**
4 | * 存储序列化后的数据到 LocalStorage
5 | * @param {string} key
6 | * @param {any} value 对象需要序列化
7 | * @param {number} expire
8 | */
9 | export function setLocal(key, value, expire = 60 * 60 * 24 * 7) {
10 | const data = JSON.stringify({
11 | value,
12 | time: Date.now(),
13 | expire: expire ? new Date().getTime() + expire * 1000 : null,
14 | })
15 | window.localStorage.setItem(key, encrypto(data)) // 加密存储
16 | }
17 |
18 | /**
19 | * 从 LocalStorage 中获取数据, 解密后反序列化, 根据是否过期来返回
20 | * @param {string} key
21 | */
22 | export function getLocal(key) {
23 | const encryptedVal = window.localStorage.getItem(key)
24 | if (encryptedVal) {
25 | const val = decrypto(encryptedVal) // 解密
26 | const { value, expire } = JSON.parse(val)
27 | // 未过期则返回
28 | if (!expire || expire > new Date().getTime()) {
29 | return value
30 | }
31 | }
32 | // 过期则移除
33 | removeLocal(key)
34 | return null
35 | }
36 |
37 | export function removeLocal(key) {
38 | window.localStorage.removeItem(key)
39 | }
40 |
41 | export function clearLocal() {
42 | window.localStorage.clear()
43 | }
44 |
45 | /**
46 | * 加密数据: Base64 加密
47 | * @param {any} data - 数据
48 | */
49 | function encrypto(data) {
50 | const newData = JSON.stringify(data)
51 | const encryptedData = btoa(CryptoSecret + newData)
52 | return encryptedData
53 | }
54 |
55 | /**
56 | * 解密数据: Base64 解密
57 | * @param {string} cipherText - 密文
58 | */
59 | function decrypto(cipherText) {
60 | const decryptedData = atob(cipherText)
61 | const originalText = decryptedData.replace(CryptoSecret, '')
62 | try {
63 | const parsedData = JSON.parse(originalText)
64 | return parsedData
65 | }
66 | catch (error) {
67 | return null
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/about/index.vue:
--------------------------------------------------------------------------------
1 |
34 |
35 |
36 |
37 |
38 |
![avatar]()
39 |
40 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/article/detail/components/Catalogue.vue:
--------------------------------------------------------------------------------
1 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | 目录
63 |
64 |
74 |
75 |
76 |
77 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/article/detail/components/Copyright.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
文章作者:
14 |
15 | {{ blogConfig.website_author }}
16 |
17 |
18 |
24 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/article/detail/components/Forward.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | {{ tag.name }}
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/article/detail/components/LastNext.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
上一篇
18 |
{{ lastArticle.title }}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
下一篇
27 |
{{ nextArticle.title }}
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/article/detail/components/LatestList.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | 最新文章
16 |
17 |
18 | -
19 |
20 |
21 |
![]()
22 |
23 |
{{ item.title }}
24 |
25 | {{ dayjs(item.created_at).format('YYYY-MM-DD') }}
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/article/detail/components/Recommend.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 | 相关推荐
14 |
15 |
16 |
17 |
18 |
19 |
![]()
20 |
21 |
22 | {{ dayjs(item.created_at).format('YYYY-MM-DD') }}
23 |
24 |
{{ item.title }}
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/article/detail/components/Reward.vue:
--------------------------------------------------------------------------------
1 |
51 |
52 |
53 |
54 |
57 |
60 |
61 |
62 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/discover/category/index.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
20 | 分类 - {{ categoryList.length }}
21 |
22 |
23 | -
27 |
28 |
29 |
30 |
31 | {{ c.name }}
32 | ({{ c.article_count ?? 0 }})
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/discover/tag/index.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 | 标签 - {{ tagList.length }}
30 |
31 |
32 |
40 | {{ t.name }}
41 |
42 |
43 |
44 |
45 |
46 |
52 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/entertainment/album/index.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 | 禁止访问
10 |
11 |
12 |

13 |
14 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/entertainment/talking/index.vue:
--------------------------------------------------------------------------------
1 |
2 | 说说
3 |
4 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/error-page/404.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 | 404 资源不存在
10 |
11 |
12 |

13 |
14 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/home/components/Announcement.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
11 |
12 | 公告
13 |
14 |
15 | {{ blogInfo.blog_config?.website_notice }}
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/home/components/TalkingCarousel.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | {{ sentence }}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
37 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/home/components/WebsiteInfo.vue:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 |
34 |
35 |
36 | 网站咨询
37 |
38 |
39 |
40 | 运行时间:
41 | {{ runTime }}
42 |
43 |
44 | 总访问量:
45 | {{ viewCount }}
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/link/components/AddLink.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | 申请友链
6 |
7 |
8 |
9 | 名称:阵、雨的个人博客
10 | 简介:往事随风而去
11 | 头像:https://foruda.gitee.com/avatar/1677041571085433939/5221991_szluyu99_1614389421.png
12 |
13 |
14 | 需要交换友链的在下方留言💖:
15 |
16 |
17 | 友链信息展示需要,您的信息格式要包含:名称、介绍、链接、头像
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/link/components/LinkList.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | 友情链接
18 |
19 |
20 |
21 |
41 |
42 |
43 |

44 |
45 |
46 | 暂无友情链接
47 |
48 |
可以在后台添加
49 |
50 |
51 |
52 |
53 |
54 |
79 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/link/index.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/user/UploadOne.vue:
--------------------------------------------------------------------------------
1 |
51 |
52 |
53 |
54 |
55 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/gin-blog-front/src/views/user/index.vue:
--------------------------------------------------------------------------------
1 |
40 |
41 |
42 |
43 |
44 | 基本信息
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
60 |
61 | {{ item.label }}
62 |
63 |
67 |
68 |
69 |
72 |
73 |
74 |
75 |
76 |
77 |
--------------------------------------------------------------------------------
/gin-blog-front/uno.config.js:
--------------------------------------------------------------------------------
1 | import {
2 | defineConfig,
3 | presetIcons,
4 | presetTypography,
5 | presetUno,
6 | transformerDirectives,
7 | transformerVariantGroup,
8 | } from 'unocss'
9 |
10 | export default defineConfig({
11 | shortcuts: [
12 | ['f-c-c', 'flex justify-center items-center'],
13 | ],
14 | presets: [
15 | presetUno(),
16 | presetIcons({ warn: true }),
17 | presetTypography(),
18 | ],
19 | transformers: [
20 | transformerDirectives(),
21 | transformerVariantGroup(),
22 | ],
23 | })
24 |
--------------------------------------------------------------------------------
/gin-blog-front/vite.config.js:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import { defineConfig, loadEnv } from 'vite'
3 | import vue from '@vitejs/plugin-vue'
4 | import unocss from 'unocss/vite'
5 | import viteCompression from 'vite-plugin-compression'
6 | import { visualizer } from 'rollup-plugin-visualizer'
7 |
8 | export default defineConfig((configEnv) => {
9 | const env = loadEnv(configEnv.mode, process.cwd())
10 |
11 | return {
12 | base: env.VITE_PUBLIC_PATH || '/',
13 | resolve: {
14 | alias: {
15 | '@': path.resolve(path.resolve(process.cwd()), 'src'),
16 | '~': path.resolve(process.cwd()),
17 | },
18 | },
19 | plugins: [
20 | vue(),
21 | unocss(),
22 | viteCompression({ algorithm: 'gzip' }),
23 | visualizer({ open: false, gzipSize: true, brotliSize: true }),
24 | ],
25 | server: {
26 | host: '0.0.0.0',
27 | port: 3333,
28 | open: false,
29 | proxy: {
30 | '/api': {
31 | target: env.VITE_BACKEND_URL,
32 | changeOrigin: true,
33 | },
34 | },
35 | },
36 | // https://cn.vitejs.dev/guide/api-javascript.html#build
37 | build: {
38 | chunkSizeWarningLimit: 1024, // chunk 大小警告的限制 (单位 kb)
39 | },
40 | esbuild: {
41 | drop: ['debugger'], // console
42 | },
43 | }
44 | })
45 |
--------------------------------------------------------------------------------
/gin-blog-server/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 |
17 | public/uploaded/
18 | public/markdown/
19 |
20 | *.db
--------------------------------------------------------------------------------
/gin-blog-server/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.21-alpine as BUILDER
2 | WORKDIR /gvb
3 |
4 | COPY go.mod go.mod
5 | COPY go.sum go.sum
6 | RUN go env -w GO111MODULE=on \
7 | && go env -w GOPROXY=https://goproxy.cn,direct \
8 | && go mod download
9 | COPY . .
10 | RUN cd cmd && go build -o server .
11 |
12 | FROM alpine:3.19
13 | ENV WORK_PATH /gvb
14 | WORKDIR ${WORK_PATH}
15 | COPY --from=0 ${WORK_PATH}/cmd/server .
16 | COPY --from=0 ${WORK_PATH}/config.docker.yml .
17 | COPY --from=0 ${WORK_PATH}/assets/ip2region.xdb ./assets/ip2region.xdb
18 | COPY --from=0 ${WORK_PATH}/assets/templates ./assets/templates
19 | EXPOSE 8765
20 |
21 | ENTRYPOINT ./server -c config.docker.yml
--------------------------------------------------------------------------------
/gin-blog-server/assets/ip2region.xdb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szluyu99/gin-vue-blog/61dd11ccd296e8642a318ada3ef7b3f7776d2410/gin-blog-server/assets/ip2region.xdb
--------------------------------------------------------------------------------
/gin-blog-server/assets/templates/base.tpl:
--------------------------------------------------------------------------------
1 | {{define "base"}}
2 |
3 |
4 |
5 |
6 |
7 | {{template "styles" .}}
8 | {{ .Subject}}
9 |
10 |
11 |
12 |
13 | |
14 |
15 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | {{block "content" .}}{{end}}
33 |
34 |
35 |
36 |
37 |
48 |
49 |
50 |
51 |
52 | |
53 | |
54 |
55 |
56 |
57 |
58 |
59 | {{end}}
60 |
--------------------------------------------------------------------------------
/gin-blog-server/assets/templates/email-verify.tpl:
--------------------------------------------------------------------------------
1 | {{template "base" .}}
2 | {{define "content"}}
3 |
4 |
5 |
6 |
7 |
8 | 👋 你好~ {{.UserName}} ~
9 | 💡 感谢您注册账户,很高兴您可以加入我们的大家庭!请激活您的账户。
10 | 📬 这只需简单一步:点击以下按钮验证您的电子邮件地址。
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | 激活账户 |
19 |
20 |
21 |
22 | |
23 |
24 |
25 |
26 | 🕹 激活后,您将完成访问!
27 | 💃 按钮没反应?尝试将此 URL 粘贴到您的浏览器中:{{.URL}}
28 | 😉 我们期待着您的到来!
29 | |
30 |
31 |
32 | |
33 |
34 | {{end}}
--------------------------------------------------------------------------------
/gin-blog-server/cmd/create-superadmin/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "errors"
5 | "flag"
6 | "fmt"
7 | ginblog "gin-blog/internal"
8 | g "gin-blog/internal/global"
9 | "gin-blog/internal/model"
10 | "gin-blog/internal/utils"
11 | "log"
12 | "log/slog"
13 | "os"
14 |
15 | "gorm.io/gorm"
16 | )
17 |
18 | func main() {
19 | username := flag.String("username", "", "超级管理员账户")
20 | password := flag.String("password", "", "超级管理员密码")
21 | configPath := flag.String("c", "../../config.yml", "配置文件路径")
22 | flag.Parse()
23 |
24 | // 根据命令行参数读取配置文件, 其他变量的初始化依赖于配置文件对象
25 | conf := g.ReadConfig(*configPath)
26 |
27 | //! 处理 sqlite3 数据库路径
28 | conf.SQLite.Dsn = "../" + conf.SQLite.Dsn
29 | conf.Server.DbLogMode = "silent"
30 |
31 | db := ginblog.InitDatabase(conf)
32 |
33 | if *username == "" || *password == "" {
34 | log.Fatal("请指定超级管理员账户和密码")
35 | }
36 |
37 | createSuperAdmin(db, *username, *password)
38 | }
39 |
40 | // 创建超级管理员
41 | func createSuperAdmin(db *gorm.DB, username, password string) {
42 | err := db.Transaction(func(tx *gorm.DB) error {
43 | var userAuth model.UserAuth
44 | err := db.Where("username = ?", username).First(&userAuth).Error
45 | if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
46 | return err
47 | }
48 |
49 | if userAuth.ID != 0 {
50 | return errors.New(userAuth.Username + " 账户已存在")
51 | }
52 |
53 | slog.Info("开始创建超级管理员")
54 |
55 | // 默认生成一个 super admin 用户
56 | hashPassword, err := utils.BcryptHash(password)
57 | if err != nil {
58 | return errors.New("密码生成失败: " + err.Error())
59 | }
60 |
61 | userAuth = model.UserAuth{
62 | Username: username,
63 | Password: hashPassword,
64 | IsSuper: true,
65 | UserInfo: &model.UserInfo{
66 | Nickname: username,
67 | Avatar: "https://cdn.hahacode.cn/config/superadmin_avatar.jpg",
68 | Intro: "这个人很懒,什么都没有留下",
69 | Website: "https://www.hahacode.cn",
70 | },
71 | }
72 | if err := db.Create(&userAuth).Error; err != nil {
73 | return err
74 | }
75 |
76 | return nil
77 | })
78 |
79 | if err != nil {
80 | slog.Error("创建超级管理员失败: " + err.Error())
81 | os.Exit(0)
82 | }
83 |
84 | slog.Info(fmt.Sprintf("创建超级管理员成功: %s, 密码: %s\n", username, password))
85 | }
86 |
--------------------------------------------------------------------------------
/gin-blog-server/cmd/create_superadmin.sh:
--------------------------------------------------------------------------------
1 | cd create-superadmin
2 |
3 | # 创建一个用户名 superadmin, 密码 superadmin 的超级管理员
4 | # 超级管理员可以访问所有页面, 所有资源
5 | go run main.go -username superadmin -password superadmin
--------------------------------------------------------------------------------
/gin-blog-server/cmd/generate_data.sh:
--------------------------------------------------------------------------------
1 | cd generate-data
2 |
3 | # all | config | auth | page | resource
4 | # all 生成所有信息
5 | # config 生成配置信息
6 | # auth 生成默认角色 admin, guest, 以及对应的默认用户 admin, guest
7 | # page 生成默认页面信息
8 | # resource 生成默认资源信息
9 | go run main.go -t "all"
--------------------------------------------------------------------------------
/gin-blog-server/cmd/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | ginblog "gin-blog/internal"
6 | g "gin-blog/internal/global"
7 | "gin-blog/internal/middleware"
8 | "log"
9 | "strings"
10 |
11 | "github.com/gin-gonic/gin"
12 | )
13 |
14 | func main() {
15 | configPath := flag.String("c", "../config.yml", "配置文件路径")
16 | flag.Parse()
17 |
18 | // 根据命令行参数读取配置文件, 其他变量的初始化依赖于配置文件对象
19 | conf := g.ReadConfig(*configPath)
20 |
21 | _ = ginblog.InitLogger(conf)
22 | db := ginblog.InitDatabase(conf)
23 | rdb := ginblog.InitRedis(conf)
24 |
25 | // 初始化 gin 服务
26 | gin.SetMode(conf.Server.Mode)
27 | r := gin.New()
28 | r.SetTrustedProxies([]string{"*"})
29 | // 开发模式使用 gin 自带的日志和恢复中间件, 生产模式使用自定义的中间件
30 | if conf.Server.Mode == "debug" {
31 | r.Use(gin.Logger(), gin.Recovery()) // gin 自带的日志和恢复中间件, 挺好用的
32 | } else {
33 | r.Use(middleware.Recovery(true), middleware.Logger())
34 | }
35 | r.Use(middleware.CORS())
36 | r.Use(middleware.WithGormDB(db))
37 | r.Use(middleware.WithRedisDB(rdb))
38 | r.Use(middleware.WithCookieStore(conf.Session.Name, conf.Session.Salt))
39 | ginblog.RegisterHandlers(r)
40 |
41 | // 使用本地文件上传, 需要静态文件服务, 使用七牛云不需要
42 | if conf.Upload.OssType == "local" {
43 | r.Static(conf.Upload.Path, conf.Upload.StorePath)
44 | }
45 |
46 | serverAddr := conf.Server.Port
47 | if serverAddr[0] == ':' || strings.HasPrefix(serverAddr, "0.0.0.0:") {
48 | log.Printf("Serving HTTP on (http://localhost:%s/) ... \n", strings.Split(serverAddr, ":")[1])
49 | } else {
50 | log.Printf("Serving HTTP on (http://%s/) ... \n", serverAddr)
51 | }
52 | r.Run(serverAddr)
53 | }
54 |
--------------------------------------------------------------------------------
/gin-blog-server/cmd/run_swag.sh:
--------------------------------------------------------------------------------
1 | # 进入根目录, 生成 swagger 文档
2 | cd ..
3 | swag init -g ./cmd/main.go
4 |
5 | echo "------swag init done------"
6 |
7 | # 回到 cmd 目录, 运行 main.go
8 | cd cmd
9 | go run main.go
--------------------------------------------------------------------------------
/gin-blog-server/config.docker.yml:
--------------------------------------------------------------------------------
1 | Server:
2 | Mode: release # debug | release
3 | Port: :8765
4 | DbType: "mysql" # mysql | sqlite
5 | DbAutoMigrate: true # 是否自动迁移数据库表结构 (表结构没变可以不迁移, 提高启动速度)
6 | JWT:
7 | Secret: "abc123321"
8 | Expire: 24 # hour
9 | Issuer: "gin-vue-blog"
10 | Mysql:
11 | Host: "127.0.0.1"
12 | Port: "3306"
13 | Config: "charset=utf8mb4&parseTime=True&loc=Local" # 其他配置, 例如时区
14 | Dbname: "gvb"
15 | Username: "root"
16 | Password: "123456"
17 | LogMode: "error" # 日志级别 silent, error, warn, info, 默认 info
18 | Sqlite:
19 | Dsn: "gvb.db"
20 | Redis:
21 | DB: 7
22 | Addr: '127.0.0.1:6379'
23 | Password: ''
24 | Session:
25 | Name: "mysession"
26 | Salt: "salt"
27 | MaxAge: 600 # second
28 | Log:
29 | Level: "error" # debug | info | warn | error
30 | Format: "text" # text | json
31 | Directory: "log"
32 | Email:
33 | Host: "smtp.163.com" # 服务器地址, 例如 smtp.qq.com 前往要发邮件的邮箱查看其 smtp 协议
34 | Port: 465 # 前往要发邮件的邮箱查看其 smtp 协议端口, 大多为 465
35 | From: "" # 发件人 (邮箱)
36 | IsSSL: true # 是否开启 SSL
37 | Secret: "" # 密钥, 不是邮箱登录密码, 是开启 smtp 服务后获取的一串验证码
38 | Nickname: "" # 发件人昵称, 通常为自己的邮箱名
39 | Captcha:
40 | SendEmail: true # 通过邮箱发送验证码
41 | ExpireTime: 15 # 过期时间 (分钟)
42 | Upload:
43 | OssType: "local" # local | qiniu
44 | Path: "public/uploaded" # 本地文件访问路径 (OssType="local" 才生效)
45 | StorePath: "public/uploaded" # 本地文件上传路径 (OssType="local" 才生效)
46 | Qiniu:
47 | ImgPath: "" # 外链
48 | Zone: ""
49 | Bucket: ""
50 | AccessKey: ""
51 | SecretKey: ""
52 | UseHttps: false
53 | UseCdnDomains: false
--------------------------------------------------------------------------------
/gin-blog-server/config.yml:
--------------------------------------------------------------------------------
1 | Server:
2 | Mode: debug # debug | release
3 | Port: :8765
4 | DbType: "mysql" # mysql | sqlite
5 | DbAutoMigrate: true # 是否自动迁移数据库表结构 (表结构没变可以不迁移, 提高启动速度)
6 | DbLogMode: "error" # 日志级别 silent, error, warn, info, 默认 info
7 | JWT:
8 | Secret: "abc123321"
9 | Expire: 24 # hour
10 | Issuer: "gin-vue-blog"
11 | Mysql:
12 | Host: "127.0.0.1"
13 | Port: "3306"
14 | Config: "charset=utf8mb4&parseTime=True&loc=Local" # 其他配置, 例如时区
15 | Dbname: "gvb"
16 | Username: "root"
17 | Password: ""
18 | Sqlite:
19 | Dsn: "gvb.db"
20 | Redis:
21 | DB: 7
22 | Addr: '127.0.0.1:6379'
23 | Password: ''
24 | Session:
25 | Name: "mysession"
26 | Salt: "salt"
27 | MaxAge: 600 # second
28 | Log:
29 | Level: "debug" # debug | info | warn | error
30 | Format: "text" # text | json
31 | Directory: "log"
32 | Email:
33 | Host: "smtp.qq.com" # 服务器地址, 例如 smtp.qq.com 前往要发邮件的邮箱查看其 smtp 协议
34 | Port: 465 # 前往要发邮件的邮箱查看其 smtp 协议端口, 大多为 465
35 | From: "" # 发件人 (邮箱)
36 | SmtpPass: "" # 密钥, 不是邮箱登录密码, 是开启 smtp 服务后获取的一串验证码
37 | SmtpUser: "" # 发件人昵称, 通常为自己的邮箱名
38 | Captcha:
39 | SendEmail: true # 通过邮箱发送验证码
40 | ExpireTime: 15 # 过期时间 (分钟)
41 | Upload:
42 | OssType: "local" # local | qiniu
43 | Path: "public/uploaded" # 本地文件访问路径: OssType="local" 生效
44 | StorePath: "../public/uploaded" # 本地文件上传路径: 相对于 main.go, OssType="local" 生效
45 | Qiniu:
46 | ImgPath: "" # 外链
47 | Zone: ""
48 | Bucket: ""
49 | AccessKey: ""
50 | SecretKey: ""
51 | UseHttps: false
52 | UseCdnDomains: false
--------------------------------------------------------------------------------
/gin-blog-server/internal/global/keys.go:
--------------------------------------------------------------------------------
1 | package g
2 |
3 | // Redis Key
4 |
5 | const (
6 | // MAIL_CODE = "mail_code:" // 验证码
7 | // DELETE = "delete:" //? 记录强制下线用户?
8 | ONLINE_USER = "online_user:" // 在线用户
9 | OFFLINE_USER = "offline_user:" // 强制下线用户
10 | VISITOR_AREA = "visitor_area" // 地域统计
11 | VIEW_COUNT = "view_count" // 访问数量
12 |
13 | KEY_UNIQUE_VISITOR_SET = "unique_visitor" // 唯一用户记录 set
14 |
15 | ARTICLE_USER_LIKE_SET = "article_user_like:" // 文章点赞 Set
16 | ARTICLE_LIKE_COUNT = "article_like_count" // 文章点赞数
17 | ARTICLE_VIEW_COUNT = "article_view_count" // 文章查看数
18 |
19 | COMMENT_USER_LIKE_SET = "comment_user_like:" // 评论点赞 Set
20 | COMMENT_LIKE_COUNT = "comment_like_count" // 评论点赞数
21 |
22 | PAGE = "page" // 页面封面
23 | CONFIG = "config" // 博客配置
24 | )
25 |
26 | // Gin Context Key | Session Key
27 |
28 | const (
29 | CTX_DB = "_db_field"
30 | CTX_RDB = "_rdb_field"
31 | CTX_USER_AUTH = "_user_auth_field"
32 | )
33 |
34 | // Config Key
35 |
36 | const (
37 | CONFIG_ARTICLE_COVER = "article_cover"
38 | CONFIG_IS_COMMENT_REVIEW = "is_comment_review"
39 | CONFIG_ABOUT = "about"
40 | )
41 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/handle/cache.go:
--------------------------------------------------------------------------------
1 | package handle
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | g "gin-blog/internal/global"
7 | "gin-blog/internal/model"
8 | "time"
9 | "github.com/go-redis/redis/v9"
10 | )
11 |
12 | // redis context
13 | var rctx = context.Background()
14 |
15 | // Page
16 |
17 | // 将页面列表缓存到 Redis 中
18 | func addPageCache(rdb *redis.Client, pages []model.Page) error {
19 | data, err := json.Marshal(pages)
20 | if err != nil {
21 | return err
22 | }
23 | return rdb.Set(rctx, g.PAGE, string(data), 0).Err()
24 | }
25 |
26 | // 删除 Redis 中页面列表缓存
27 | func removePageCache(rdb *redis.Client) error {
28 | return rdb.Del(rctx, g.PAGE).Err()
29 | }
30 |
31 | // 从 Redis 中获取页面列表缓存
32 | // rdb.Get 如果不存在 key, 会返回 redis.Nil 错误
33 | func getPageCache(rdb *redis.Client) (cache []model.Page, err error) {
34 | s, err := rdb.Get(rctx, g.PAGE).Result()
35 | if err != nil {
36 | return nil, err
37 | }
38 |
39 | if err := json.Unmarshal([]byte(s), &cache); err != nil {
40 | return nil, err
41 | }
42 |
43 | return cache, nil
44 | }
45 |
46 | // Config
47 |
48 | // 将博客配置缓存到 Redis 中
49 | func addConfigCache(rdb *redis.Client, config map[string]string) error {
50 | return rdb.HMSet(rctx, g.CONFIG, config).Err()
51 | }
52 |
53 | // 删除 Redis 中博客配置缓存
54 | func removeConfigCache(rdb *redis.Client) error {
55 | return rdb.Del(rctx, g.CONFIG).Err()
56 | }
57 |
58 | // 从 Redis 中获取博客配置缓存
59 | // rdb.HGetAll 如果不存在 key, 不会返回 redis.Nil 错误, 而是返回空 map
60 | func getConfigCache(rdb *redis.Client) (cache map[string]string, err error) {
61 | return rdb.HGetAll(rctx, g.CONFIG).Result()
62 | }
63 |
64 | // email
65 | func SetMailInfo (rdb *redis.Client,info string,expire time.Duration) error{
66 | return rdb.Set(rctx,info,true,expire).Err()
67 | }
68 | func GetMailInfo (rdb *redis.Client,info string) (bool,error){
69 | return rdb.Get(rctx,info).Bool()
70 | }
71 | func DeleteMailInfo(rdb *redis.Client,info string) error{
72 | return rdb.Del(rctx,info).Err()
73 | }
--------------------------------------------------------------------------------
/gin-blog-server/internal/handle/cache_test.go:
--------------------------------------------------------------------------------
1 | package handle
2 |
3 | import (
4 | "context"
5 | "gin-blog/internal/model"
6 | "log"
7 | "testing"
8 |
9 | "github.com/go-redis/redis/v9"
10 | "github.com/stretchr/testify/assert"
11 | )
12 |
13 | // 需要 Redis 环境
14 | func initRdb() *redis.Client {
15 | rdb := redis.NewClient(&redis.Options{
16 | Addr: "localhost:6379",
17 | Password: "",
18 | DB: 11,
19 | })
20 |
21 | _, err := rdb.Ping(context.Background()).Result()
22 | if err != nil {
23 | log.Fatal("Redis 连接失败: ", err)
24 | }
25 |
26 | return rdb
27 | }
28 |
29 | func TestPageCache(t *testing.T) {
30 | rdb := initRdb()
31 |
32 | pages := []model.Page{
33 | {Name: "page1"},
34 | {Name: "page2"},
35 | }
36 |
37 | // 直接获取缓存
38 | // 不存在, 返回 redis.Nil 错误
39 | {
40 | cache, err := getPageCache(rdb)
41 | assert.Equal(t, redis.Nil, err)
42 | assert.Nil(t, cache)
43 | }
44 |
45 | // 新增, 获取 缓存
46 | {
47 | err := addPageCache(rdb, pages)
48 | assert.Nil(t, err)
49 |
50 | cache, err := getPageCache(rdb)
51 | assert.Nil(t, err)
52 | assert.Equal(t, pages, cache)
53 | }
54 |
55 | // 删除, 获取 缓存
56 | // 不存在, 返回 redis.Nil 错误
57 | {
58 | err := removePageCache(rdb)
59 | assert.Nil(t, err)
60 |
61 | cache, err := getPageCache(rdb)
62 | assert.Equal(t, redis.Nil, err)
63 | assert.Nil(t, cache)
64 | }
65 |
66 | }
67 |
68 | func TestConfigCache(t *testing.T) {
69 | rdb := initRdb()
70 |
71 | config := map[string]string{
72 | "name": "name",
73 | "url": "url",
74 | }
75 |
76 | // 直接获取缓存
77 | // 不存在, 返回空 map
78 | {
79 | cache, err := getConfigCache(rdb)
80 | assert.Nil(t, err)
81 | assert.Empty(t, cache)
82 | }
83 |
84 | // 新增, 获取 缓存
85 | {
86 | err := addConfigCache(rdb, config)
87 | assert.Nil(t, err)
88 |
89 | cache, err := getConfigCache(rdb)
90 | assert.Nil(t, err)
91 | assert.Equal(t, config, cache)
92 | }
93 |
94 | // 删除, 获取 缓存
95 | // 不存在, 返回空 map
96 | {
97 | err := removeConfigCache(rdb)
98 | assert.Nil(t, err)
99 |
100 | cache, err := getConfigCache(rdb)
101 | assert.Nil(t, err)
102 | assert.Empty(t, cache)
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/handle/handle_operationlog.go:
--------------------------------------------------------------------------------
1 | package handle
2 |
3 | import (
4 | g "gin-blog/internal/global"
5 | "gin-blog/internal/model"
6 |
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | type OperationLog struct{}
11 |
12 | // @Summary 获取操作日志列表
13 | // @Description 根据条件查询获取操作日志列表
14 | // @Tags OperationLog
15 | // @Accept json
16 | // @Produce json
17 | // @Param page_num query int false "页码"
18 | // @Param page_size query int false "每页数量"
19 | // @Param keyword query string false "关键字"
20 | // @Success 0 {object} Response[[]model.OperationLog]
21 | // @Security ApiKeyAuth
22 | // @Router /operation/log/list [get]
23 | func (*OperationLog) GetList(c *gin.Context) {
24 | var query PageQuery
25 | if err := c.ShouldBindQuery(&query); err != nil {
26 | ReturnError(c, g.ErrRequest, err)
27 | return
28 | }
29 |
30 | list, total, err := model.GetOperationLogList(GetDB(c), query.Page, query.Size, query.Keyword)
31 | if err != nil {
32 | ReturnError(c, g.ErrDbOp, err)
33 | return
34 | }
35 |
36 | ReturnSuccess(c, PageResult[model.OperationLog]{
37 | Total: total,
38 | List: list,
39 | Size: query.Size,
40 | Page: query.Page,
41 | })
42 | }
43 |
44 | // @Summary 删除操作日志
45 | // @Description 删除操作日志
46 | // @Tags OperationLog
47 | // @Accept json
48 | // @Produce json
49 | // @Param ids body []int true "操作日志ID列表"
50 | // @Success 0 {object} Response[int]
51 | // @Security ApiKeyAuth
52 | // @Router /operation/log [delete]
53 | func (*OperationLog) Delete(c *gin.Context) {
54 | var ids []int
55 | if err := c.ShouldBindJSON(&ids); err != nil {
56 | ReturnError(c, g.ErrRequest, err)
57 | return
58 | }
59 |
60 | result := GetDB(c).Delete(&model.OperationLog{}, "id in ?", ids)
61 | if result.Error != nil {
62 | ReturnError(c, g.ErrDbOp, result.Error)
63 | return
64 | }
65 |
66 | ReturnSuccess(c, result.RowsAffected)
67 | }
68 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/handle/handle_upload.go:
--------------------------------------------------------------------------------
1 | package handle
2 |
3 | import (
4 | g "gin-blog/internal/global"
5 | "gin-blog/internal/utils/upload"
6 |
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | type Upload struct{}
11 |
12 | // @Summary 上传文件
13 | // @Description 上传文件
14 | // @Tags upload
15 | // @Accept multipart/form-data
16 | // @Produce json
17 | // @Param file formData file true "文件"
18 | // @Success 0 {object} Response[string]
19 | // @Router /upload/file [post]
20 | func (*Upload) UploadFile(c *gin.Context) {
21 | _, fileHeader, err := c.Request.FormFile("file")
22 | if err != nil {
23 | ReturnError(c, g.ErrFileReceive, err)
24 | return
25 | }
26 |
27 | oss := upload.NewOSS()
28 | filePath, _, err := oss.UploadFile(fileHeader)
29 | if err != nil {
30 | ReturnError(c, g.ErrFileUpload, err)
31 | return
32 | }
33 |
34 | ReturnSuccess(c, filePath)
35 | }
36 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/middleware/listen_online.go:
--------------------------------------------------------------------------------
1 | package middleware
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | g "gin-blog/internal/global"
7 | "gin-blog/internal/handle"
8 | "strconv"
9 | "time"
10 |
11 | "github.com/gin-gonic/gin"
12 | "github.com/go-redis/redis/v9"
13 | )
14 |
15 | // 监听在线状态中间件
16 | // 登录时: 移除用户的强制下线标记
17 | // 退出登录时: 添加用户的在线标记
18 | func ListenOnline() gin.HandlerFunc {
19 | return func(c *gin.Context) {
20 | ctx := context.Background()
21 | rdb := c.MustGet(g.CTX_RDB).(*redis.Client)
22 |
23 | auth, err := handle.CurrentUserAuth(c)
24 | if err != nil {
25 | handle.ReturnError(c, g.ErrUserAuth, err)
26 | return
27 | }
28 |
29 | onlineKey := g.ONLINE_USER + strconv.Itoa(auth.ID)
30 | offlineKey := g.OFFLINE_USER + strconv.Itoa(auth.ID)
31 |
32 | // 判断当前用户是否被强制下线
33 | if rdb.Exists(ctx, offlineKey).Val() == 1 {
34 | fmt.Println("用户被强制下线")
35 | handle.ReturnError(c, g.ErrForceOffline, nil)
36 | c.Abort()
37 | return
38 | }
39 |
40 | // 每次发送请求会更新 Redis 中的在线状态: 重新计算 10 分钟
41 | rdb.Set(ctx, onlineKey, auth, 10*time.Minute)
42 | c.Next()
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/article_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/glebarez/sqlite"
7 | "github.com/stretchr/testify/assert"
8 | "gorm.io/gorm"
9 | )
10 |
11 | func createRole(t *testing.T, db *gorm.DB, name, label string) *Role {
12 | role := Role{
13 | Name: name,
14 | Label: label,
15 | }
16 | err := db.Create(&role).Error
17 | assert.Nil(t, err)
18 | return &role
19 | }
20 |
21 | func createUser(t *testing.T, db *gorm.DB, username, nickname string) *UserAuth {
22 | userAuth := UserAuth{
23 | Username: username,
24 | Password: "123456",
25 | UserInfo: &UserInfo{
26 | Nickname: nickname,
27 | },
28 | }
29 | db.Create(&userAuth)
30 |
31 | val, err := GetUserAuthInfoById(db, userAuth.ID)
32 | assert.Nil(t, err)
33 | assert.Equal(t, userAuth.ID, val.ID)
34 | assert.Equal(t, username, val.Username)
35 | assert.Equal(t, nickname, val.UserInfo.Nickname)
36 | return &userAuth
37 | }
38 |
39 | func TestArticlePreload(t *testing.T) {
40 | db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
41 | MakeMigrate(db)
42 |
43 | u1 := createUser(t, db, "u1", "n1")
44 | assert.NotNil(t, u1)
45 |
46 | article := Article{
47 | Title: "title",
48 | Content: "content",
49 | UserId: u1.ID,
50 | Category: &Category{
51 | Name: "category",
52 | },
53 | Tags: []*Tag{
54 | {Name: "tag1"},
55 | {Name: "tag2"},
56 | },
57 | }
58 | err := db.Create(&article).Error
59 | assert.Nil(t, err)
60 |
61 | var val Article
62 | err = db.Where("id = ?", article.ID).
63 | Preload("User").
64 | Preload("Category").
65 | Preload("Tags").
66 | First(&val).Error
67 | assert.Nil(t, err)
68 |
69 | assert.Equal(t, article.ID, val.ID)
70 | assert.Equal(t, article.Title, val.Title)
71 | assert.Equal(t, article.UserId, val.UserId)
72 | assert.Equal(t, article.Category.Name, val.Category.Name)
73 | assert.Equal(t, article.Tags[0].Name, val.Tags[0].Name)
74 | assert.Equal(t, article.Tags[1].Name, val.Tags[1].Name)
75 | assert.Equal(t, u1.ID, val.User.ID)
76 | }
77 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/auth_control_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/glebarez/sqlite"
7 | "github.com/stretchr/testify/assert"
8 | "gorm.io/gorm"
9 | "gorm.io/gorm/schema"
10 | )
11 |
12 | func initModelDB() (*gorm.DB, error) {
13 | db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
14 | SkipDefaultTransaction: true,
15 | NamingStrategy: schema.NamingStrategy{
16 | SingularTable: true, //
17 | },
18 | })
19 | if err != nil {
20 | return nil, err
21 | }
22 |
23 | MakeMigrate(db)
24 | return db, nil
25 | }
26 |
27 | func TestAuth(t *testing.T) {
28 | db, _ := initModelDB()
29 |
30 | p1, _ := AddResource(db, "api_1", "/v1/api_1", "GET", false)
31 | p2, _ := AddResource(db, "api_2", "/v1/api_2", "POST", false)
32 |
33 | // 添加拥有两个资源的角色
34 | role1, _ := AddRoleWithResources(db, "admin", "管理员", []int{p1.ID, p2.ID})
35 | resources, _ := GetResourcesByRole(db, role1.ID)
36 | assert.Len(t, resources, 2)
37 |
38 | // 修改角色资源为一个
39 | role2, _ := UpdateRoleWithResources(db, role1.ID, "super", "超管", []int{p1.ID})
40 | resources, _ = GetResourcesByRole(db, role2.ID)
41 | assert.Len(t, resources, 1)
42 |
43 | // 测试角色资源鉴权
44 | {
45 | flag, _ := CheckRoleAuth(db, role2.ID, "/v1/api_1", "GET")
46 | assert.True(t, flag)
47 | flag, _ = CheckRoleAuth(db, role2.ID, "/v1/api_99", "POST")
48 | assert.False(t, flag)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/category.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "gorm.io/gorm"
5 | )
6 |
7 | // hasMany: 一个分类下可以有多篇文章
8 | type Category struct {
9 | Model
10 | Name string `gorm:"unique;type:varchar(20);not null" json:"name"`
11 | Articles []Article `gorm:"foreignKey:CategoryId"`
12 | }
13 |
14 | type CategoryVO struct {
15 | Category
16 | ArticleCount int `json:"article_count"`
17 | }
18 |
19 | func GetCategoryList(db *gorm.DB, num, size int, keyword string) ([]CategoryVO, int64, error) {
20 | var list = make([]CategoryVO, 0)
21 | var total int64
22 |
23 | db = db.Table("category c").
24 | Select("c.id", "c.name", "COUNT(a.id) AS article_count", "c.created_at", "c.updated_at").
25 | Joins("LEFT JOIN article a ON c.id = a.category_id AND a.is_delete = 0 AND a.status = 1")
26 |
27 | if keyword != "" {
28 | db = db.Where("name LIKE ?", "%"+keyword+"%")
29 | }
30 |
31 | result := db.Group("c.id").
32 | Order("c.updated_at DESC").
33 | Count(&total).
34 | Scopes(Paginate(num, size)).
35 | Find(&list)
36 |
37 | return list, total, result.Error
38 | }
39 |
40 | func GetCategoryOption(db *gorm.DB) ([]OptionVO, error) {
41 | var list = make([]OptionVO, 0)
42 | result := db.Model(&Category{}).Select("id", "name").Find(&list)
43 | return list, result.Error
44 | }
45 |
46 | func GetCategoryById(db *gorm.DB, id int) (*Category, error) {
47 | var category Category
48 | result := db.Where("id", id).First(&category)
49 | return &category, result.Error
50 | }
51 |
52 | func GetCategoryByName(db *gorm.DB, name string) (*Category, error) {
53 | var category Category
54 | result := db.Where("name", name).First(&category)
55 | return &category, result.Error
56 | }
57 |
58 | func DeleteCategory(db *gorm.DB, ids []int) (int64, error) {
59 | result := db.Where("id IN ?", ids).Delete(Category{})
60 | if result.Error != nil {
61 | return 0, result.Error
62 | }
63 | return result.RowsAffected, nil
64 | }
65 |
66 | func SaveOrUpdateCategory(db *gorm.DB, id int, name string) (*Category, error) {
67 | category := Category{
68 | Model: Model{ID: id},
69 | Name: name,
70 | }
71 |
72 | var result *gorm.DB
73 | if id > 0 {
74 | result = db.Updates(category)
75 | } else {
76 | result = db.Create(&category)
77 | }
78 |
79 | return &category, result.Error
80 | }
81 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/comment_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/glebarez/sqlite"
7 | "github.com/stretchr/testify/assert"
8 | "gorm.io/gorm"
9 | )
10 |
11 | // test associate create
12 | func TestAssociateCreate(t *testing.T) {
13 | db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
14 | MakeMigrate(db)
15 |
16 | userAuth := UserAuth{
17 | Username: "test",
18 | Password: "test",
19 | UserInfo: &UserInfo{
20 | Nickname: "test",
21 | },
22 | }
23 |
24 | db.Create(&userAuth)
25 | assert.Equal(t, 1, userAuth.ID)
26 | assert.Equal(t, userAuth.UserInfo.ID, userAuth.UserInfoId)
27 | assert.Equal(t, "test", userAuth.Username)
28 | }
29 |
30 | func TestGetCommentList(t *testing.T) {
31 | db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
32 | MakeMigrate(db)
33 |
34 | user := UserAuth{
35 | Username: "username",
36 | Password: "123456",
37 | UserInfo: &UserInfo{
38 | Nickname: "nickname",
39 | },
40 | }
41 | db.Create(&user)
42 |
43 | article := Article{Title: "title", Content: "content"}
44 | db.Create(&article)
45 |
46 | comment, _ := AddComment(db, user.ID, TYPE_ARTICLE, article.ID, "content", true)
47 | _, _ = ReplyComment(db, user.ID, user.ID, comment.ID, "reply_content", true)
48 |
49 | data, total, err := GetCommentList(db, 1, 10, TYPE_ARTICLE, nil, "")
50 | assert.Nil(t, err)
51 | assert.Equal(t, 2, int(total))
52 | assert.Equal(t, "reply_content", data[0].Content)
53 | assert.Equal(t, "content", data[1].Content)
54 |
55 | v1 := data[0]
56 | assert.Equal(t, "reply_content", v1.Content)
57 | assert.Equal(t, "username", v1.User.Username) // preload userAuth
58 | assert.Equal(t, "nickname", v1.User.UserInfo.Nickname) // preload userAuth.userInfo
59 | assert.Equal(t, "username", v1.ReplyUser.Username) // preload replyUser
60 | assert.Equal(t, "nickname", v1.ReplyUser.UserInfo.Nickname) // preload replyUser.userInfo
61 | assert.Equal(t, "title", v1.Article.Title) // preload article
62 | }
63 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/config.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "strconv"
5 |
6 | "gorm.io/gorm"
7 | )
8 |
9 | type Config struct {
10 | Model
11 | Key string `gorm:"unique;type:varchar(256)" json:"key"`
12 | Value string `gorm:"type:varchar(256)" json:"value"`
13 | Desc string `gorm:"type:varchar(256)" json:"desc"`
14 | }
15 |
16 | func GetConfigMap(db *gorm.DB) (map[string]string, error) {
17 | var configs []Config
18 | result := db.Find(&configs)
19 | if result.Error != nil {
20 | return nil, result.Error
21 | }
22 |
23 | m := make(map[string]string)
24 | for _, config := range configs {
25 | m[config.Key] = config.Value
26 | }
27 |
28 | return m, nil
29 | }
30 |
31 | func CheckConfigMap(db *gorm.DB, m map[string]string) error {
32 | return db.Transaction(func(tx *gorm.DB) error {
33 | for k, v := range m {
34 | result := tx.Model(Config{}).Where("key", k).Update("value", v)
35 | if result.Error != nil {
36 | return result.Error
37 | }
38 | }
39 | return nil
40 | })
41 | }
42 |
43 | func CheckConfig(db *gorm.DB, key, value string) error {
44 | var config Config
45 |
46 | result := db.Where("key", key).FirstOrCreate(&config)
47 | if result.Error != nil {
48 | return result.Error
49 | }
50 |
51 | config.Value = value
52 | result = db.Save(&config)
53 |
54 | return result.Error
55 | }
56 |
57 | func GetConfig(db *gorm.DB, key string) string {
58 | var config Config
59 | result := db.Where("key", key).First(&config)
60 |
61 | if result.Error != nil {
62 | return ""
63 | }
64 |
65 | return config.Value
66 | }
67 |
68 | func GetConfigBool(db *gorm.DB, key string) bool {
69 | val := GetConfig(db, key)
70 | if val == "" {
71 | return false
72 | }
73 | return val == "true"
74 | }
75 |
76 | func GetConfigInt(db *gorm.DB, key string) int {
77 | val := GetConfig(db, key)
78 | if val == "" {
79 | return 0
80 | }
81 | result, err := strconv.Atoi(val)
82 | if err != nil {
83 | return 0
84 | }
85 | return result
86 | }
87 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/config_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestGetConfigMap(t *testing.T) {
10 | db := initDB()
11 | db.AutoMigrate(&Config{})
12 |
13 | configs := []Config{
14 | {Key: "name", Value: "Blog", Desc: "姓名"},
15 | {Key: "age", Value: "12", Desc: "年龄"},
16 | {Key: "enabled", Value: "true", Desc: "是否可用"},
17 | }
18 | db.Create(&configs)
19 |
20 | data, err := GetConfigMap(db)
21 | assert.Nil(t, err)
22 | assert.Len(t, data, 3)
23 | assert.Equal(t, "Blog", data["name"])
24 | assert.Equal(t, "12", data["age"])
25 | assert.Equal(t, "true", data["enabled"])
26 | }
27 |
28 | func TestUpdateConfigMap(t *testing.T) {
29 | db := initDB()
30 | db.AutoMigrate(&Config{})
31 |
32 | configs := []Config{
33 | {Key: "name", Value: "Blog", Desc: "姓名"},
34 | {Key: "age", Value: "12", Desc: "年龄"},
35 | {Key: "enabled", Value: "true", Desc: "是否可用"},
36 | }
37 | db.Create(&configs)
38 |
39 | m := map[string]string{
40 | "name": "Alice",
41 | "age": "15",
42 | "enabled": "false",
43 | "dump": "dump", // 无效数据
44 | }
45 | err := CheckConfigMap(db, m)
46 | assert.Nil(t, err)
47 |
48 | data, err := GetConfigMap(db)
49 | assert.Nil(t, err)
50 | assert.Len(t, data, 3)
51 | assert.Equal(t, "Alice", data["name"])
52 | assert.Equal(t, "15", data["age"])
53 | assert.Equal(t, "false", data["enabled"])
54 | }
55 |
56 | func TestConfigSetGet(t *testing.T) {
57 | db := initDB()
58 | db.AutoMigrate(&Config{})
59 |
60 | CheckConfig(db, "name", "AAA")
61 |
62 | val := GetConfig(db, "name")
63 | assert.Equal(t, "AAA", val)
64 |
65 | m, _ := GetConfigMap(db)
66 | assert.Len(t, m, 1)
67 | }
68 |
69 | func TestCheckConfig(t *testing.T) {
70 | db := initDB()
71 | db.AutoMigrate(&Config{})
72 |
73 | {
74 | CheckConfig(db, "name", "AAA")
75 | val := GetConfig(db, "name")
76 | assert.Equal(t, "AAA", val)
77 | }
78 |
79 | {
80 | CheckConfig(db, "name", "BBB")
81 | val := GetConfig(db, "name")
82 | assert.Equal(t, "BBB", val)
83 | }
84 |
85 | }
86 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/friend_link.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "gorm.io/gorm"
5 | )
6 |
7 | type FriendLink struct {
8 | Model
9 | Name string `gorm:"type:varchar(50)" json:"name"`
10 | Avatar string `gorm:"type:varchar(255)" json:"avatar"`
11 | Address string `gorm:"type:varchar(255)" json:"address"`
12 | Intro string `gorm:"type:varchar(255)" json:"intro"`
13 | }
14 |
15 | func GetLinkList(db *gorm.DB, num, size int, keyword string) (list []FriendLink, total int64, err error) {
16 | db = db.Model(&FriendLink{})
17 | if keyword != "" {
18 | db = db.Where("name LIKE ?", "%"+keyword+"%")
19 | db = db.Or("address LIKE ?", "%"+keyword+"%")
20 | db = db.Or("intro LIKE ?", "%"+keyword+"%")
21 | }
22 | db.Count(&total)
23 | result := db.Order("created_at DESC").
24 | Scopes(Paginate(num, size)).
25 | Find(&list)
26 | return list, total, result.Error
27 | }
28 |
29 | func SaveOrUpdateLink(db *gorm.DB, id int, name, avatar, address, intro string) (*FriendLink, error) {
30 | link := FriendLink{
31 | Model: Model{ID: id},
32 | Name: name,
33 | Avatar: avatar,
34 | Address: address,
35 | Intro: intro,
36 | }
37 |
38 | var result *gorm.DB
39 | if id > 0 {
40 | result = db.Updates(&link)
41 | } else {
42 | result = db.Create(&link)
43 | }
44 |
45 | return &link, result.Error
46 | }
47 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/front.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "gorm.io/gorm"
5 | )
6 |
7 | type FrontHomeVO struct {
8 | ArticleCount int64 `json:"article_count"` // 文章数量
9 | UserCount int64 `json:"user_count"` // 用户数量
10 | MessageCount int64 `json:"message_count"` // 留言数量
11 | CategoryCount int64 `json:"category_count"` // 分类数量
12 | TagCount int64 `json:"tag_count"` // 标签数量
13 | ViewCount int64 `json:"view_count"` // 访问量
14 | Config map[string]string `json:"blog_config"` // 博客信息
15 | // PageList []Page `json:"page_list"` // 页面列表
16 | }
17 |
18 | func GetFrontStatistics(db *gorm.DB) (data FrontHomeVO, err error) {
19 | result := db.Model(&Article{}).Where("status = ? AND is_delete = ?", 1, 0).Count(&data.ArticleCount)
20 | if result.Error != nil {
21 | return data, result.Error
22 | }
23 |
24 | result = db.Model(&UserAuth{}).Count(&data.UserCount)
25 | if result.Error != nil {
26 | return data, result.Error
27 | }
28 |
29 | result = db.Model(&Message{}).Count(&data.MessageCount)
30 | if result.Error != nil {
31 | return data, result.Error
32 | }
33 |
34 | result = db.Model(&Category{}).Count(&data.CategoryCount)
35 | if result.Error != nil {
36 | return data, result.Error
37 | }
38 |
39 | result = db.Model(&Tag{}).Count(&data.TagCount)
40 | if result.Error != nil {
41 | return data, result.Error
42 | }
43 |
44 | data.Config, err = GetConfigMap(db)
45 | if err != nil {
46 | return data, err
47 | }
48 |
49 | return data, nil
50 | }
51 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/message.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "gorm.io/gorm"
5 | )
6 |
7 | type Message struct {
8 | Model
9 | Nickname string `gorm:"type:varchar(50);comment:昵称" json:"nickname"`
10 | Avatar string `gorm:"type:varchar(255);comment:头像地址" json:"avatar"`
11 | Content string `gorm:"type:varchar(255);comment:留言内容" json:"content"`
12 | IpAddress string `gorm:"type:varchar(50);comment:IP 地址" json:"ip_address"`
13 | IpSource string `gorm:"type:varchar(255);comment:IP 来源" json:"ip_source"`
14 | Speed int `gorm:"type:tinyint(1);comment:弹幕速度" json:"speed"`
15 | IsReview bool `json:"is_review"`
16 | }
17 |
18 | func GetMessageList(db *gorm.DB, num, size int, nickname string, isReview *bool) (list []Message, total int64, err error) {
19 | db = db.Model(&Message{})
20 |
21 | if nickname != "" {
22 | db = db.Where("nickname LIKE ?", "%"+nickname+"%")
23 | }
24 |
25 | if isReview != nil {
26 | db = db.Where("is_review = ?", isReview)
27 | }
28 |
29 | db.Count(&total)
30 | result := db.Order("created_at DESC").Scopes(Paginate(num, size)).Find(&list)
31 | return list, total, result.Error
32 | }
33 |
34 | func DeleteMessages(db *gorm.DB, ids []int) (int64, error) {
35 | result := db.Where("id in ?", ids).Delete(&Message{})
36 | return result.RowsAffected, result.Error
37 | }
38 |
39 | func UpdateMessagesReview(db *gorm.DB, ids []int, isReview bool) (int64, error) {
40 | result := db.Model(&Message{}).Where("id in ?", ids).Update("is_review", isReview)
41 | return result.RowsAffected, result.Error
42 | }
43 |
44 | func SaveMessage(db *gorm.DB, nickname, avatar, content, address, source string, speed int, isReview bool) (*Message, error) {
45 | message := Message{
46 | Nickname: nickname,
47 | Avatar: avatar,
48 | Content: content,
49 | IpAddress: address,
50 | IpSource: source,
51 | Speed: speed,
52 | IsReview: isReview,
53 | }
54 |
55 | result := db.Create(&message)
56 | return &message, result.Error
57 | }
58 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/operation_log.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "gorm.io/gorm"
4 |
5 | type OperationLog struct {
6 | Model
7 |
8 | OptModule string `gorm:"type:varchar(50);comment:操作模块" json:"opt_module"`
9 | OptType string `gorm:"type:varchar(50);comment:操作类型" json:"opt_type"`
10 | OptMethod string `gorm:"type:varchar(100);comment:操作方法" json:"opt_method"`
11 | OptUrl string `gorm:"type:varchar(255);comment:操作URL" json:"opt_url"`
12 | OptDesc string `gorm:"type:varchar(255);comment:操作描述" json:"opt_desc"`
13 |
14 | RequestParam string `gorm:"type:longtext;comment:请求参数" json:"request_param"`
15 | RequestMethod string `gorm:"type:longtext;comment:请求方法" json:"request_method"`
16 | ResponseData string `gorm:"type:longtext;comment:响应数据" json:"response_data"`
17 |
18 | UserId int `gorm:"comment:用户ID" json:"user_id"`
19 | Nickname string `gorm:"type:varchar(50);comment:用户昵称" json:"nickname"`
20 | IpAddress string `gorm:"type:varchar(255);comment:操作IP" json:"ip_address"`
21 | IpSource string `gorm:"type:varchar(255);comment:操作地址" json:"ip_source"`
22 | }
23 |
24 | func GetOperationLogList(db *gorm.DB, num, size int, keyword string) (data []OperationLog, total int64, err error) {
25 | db = db.Model(&OperationLog{})
26 | if keyword != "" {
27 | db = db.Where("opt_module LIKE ?", "%"+keyword+"%").
28 | Or("opt_desc LIKE ?", "%"+keyword+"%")
29 | }
30 | db.Count(&total)
31 | result := db.Order("created_at DESC").
32 | Scopes(Paginate(num, size)).
33 | Find(&data)
34 | return data, total, result.Error
35 | }
36 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/page.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "gorm.io/gorm"
5 | )
6 |
7 | type Page struct {
8 | Model
9 | Name string `gorm:"unique;type:varchar(20)" json:"name"`
10 | Label string `gorm:"unique;type:varchar(30)" json:"label"`
11 | Cover string `gorm:"type:varchar(255)" json:"cover"`
12 | }
13 |
14 | func GetPageList(db *gorm.DB) ([]Page, int64, error) {
15 | var pages = make([]Page, 0)
16 | var total int64
17 |
18 | result := db.Model(&Page{}).Count(&total).Find(&pages)
19 | return pages, total, result.Error
20 | }
21 |
22 | func SaveOrUpdatePage(db *gorm.DB, id int, name, label, cover string) (*Page, error) {
23 | page := Page{
24 | Model: Model{ID: id},
25 | Name: name,
26 | Label: label,
27 | Cover: cover,
28 | }
29 |
30 | var result *gorm.DB
31 | if id > 0 {
32 | result = db.Updates(&page)
33 | } else {
34 | result = db.Create(&page)
35 | }
36 |
37 | return &page, result.Error
38 | }
39 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/page_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestGetPageList(t *testing.T) {
10 | db, _ := initModelDB()
11 |
12 | db.Create(&Page{Name: "name1", Label: "label1", Cover: "cover1"})
13 | db.Create(&Page{Name: "name2", Label: "label2", Cover: "cover2"})
14 | db.Create(&Page{Name: "name3", Label: "label3", Cover: "cover3"})
15 | db.Create(&Page{Name: "name4", Label: "label4", Cover: "cover4"})
16 | db.Create(&Page{Name: "name5", Label: "label5", Cover: "cover5"})
17 |
18 | list, total, err := GetPageList(db)
19 | assert.Nil(t, err)
20 | assert.Equal(t, int64(5), total)
21 | assert.Equal(t, 5, len(list))
22 | }
23 |
24 | func TestSaveOrUpdatePage(t *testing.T) {
25 | db, _ := initModelDB()
26 |
27 | page, err := SaveOrUpdatePage(db, 0, "name", "label", "cover")
28 | assert.Nil(t, err)
29 | assert.Equal(t, "name", page.Name)
30 | assert.Equal(t, "label", page.Label)
31 | assert.Equal(t, "cover", page.Cover)
32 |
33 | page, err = SaveOrUpdatePage(db, page.ID, "name2", "label2", "cover2")
34 | assert.Nil(t, err)
35 | assert.Equal(t, "name2", page.Name)
36 | assert.Equal(t, "label2", page.Label)
37 | assert.Equal(t, "cover2", page.Cover)
38 |
39 | var val Page
40 | db.First(&val, page.ID)
41 | assert.Equal(t, "name2", val.Name)
42 | assert.Equal(t, "label2", val.Label)
43 | assert.Equal(t, "cover2", val.Cover)
44 | }
45 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/tag.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "time"
5 |
6 | "gorm.io/gorm"
7 | )
8 |
9 | type Tag struct {
10 | Model
11 | Name string `gorm:"unique;type:varchar(20);not null" json:"name"`
12 |
13 | Articles []*Article `gorm:"many2many:article_tag;" json:"articles,omitempty"`
14 | }
15 |
16 | type TagVO struct {
17 | ID uint `json:"id"`
18 | CreatedAt time.Time `json:"created_at"`
19 | UpdatedAt time.Time `json:"updated_at"`
20 |
21 | Name string `json:"name"`
22 | ArticleCount int `json:"article_count"`
23 | }
24 |
25 | func GetTagList(db *gorm.DB, page, size int, keyword string) (list []TagVO, total int64, err error) {
26 | db = db.Table("tag t").
27 | Joins("LEFT JOIN article_tag at ON t.id = at.tag_id").
28 | Select("t.id", "t.name", "COUNT(at.article_id) AS article_count", "t.created_at", "t.updated_at")
29 |
30 | if keyword != "" {
31 | db = db.Where("name LIKE ?", "%"+keyword+"%")
32 | }
33 |
34 | result := db.
35 | Group("t.id").Order("t.updated_at DESC").
36 | Count(&total).
37 | Scopes(Paginate(page, size)).
38 | Find(&list)
39 |
40 | return list, total, result.Error
41 | }
42 |
43 | func GetTagOption(db *gorm.DB) ([]OptionVO, error) {
44 | list := make([]OptionVO, 0)
45 | result := db.Model(&Tag{}).Select("id", "name").Find(&list)
46 | return list, result.Error
47 | }
48 |
49 | // 根据 [文章id] 获取 [标签名称列表]
50 | func GetTagNamesByArticleId(db *gorm.DB, id int) ([]string, error) {
51 | list := make([]string, 0)
52 | result := db.Table("tag").
53 | Joins("LEFT JOIN article_tag ON tag.id = article_tag.tag_id").
54 | Where("article_id", id).
55 | Pluck("name", &list)
56 | return list, result.Error
57 | }
58 |
59 | func SaveOrUpdateTag(db *gorm.DB, id int, name string) (*Tag, error) {
60 | tag := Tag{
61 | Model: Model{ID: id},
62 | Name: name,
63 | }
64 |
65 | var result *gorm.DB
66 | if id > 0 {
67 | result = db.Updates(tag)
68 | } else {
69 | result = db.Create(&tag)
70 | }
71 |
72 | return &tag, result.Error
73 | }
74 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/user_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | "gorm.io/gorm"
8 | )
9 |
10 | func TestUpdateUserPassword(t *testing.T) {
11 | db := initDB()
12 | db.AutoMigrate(&UserAuth{}, &UserInfo{})
13 |
14 | var auth = UserAuth{
15 | Username: "test",
16 | Password: "123456",
17 | }
18 | db.Create(&auth)
19 |
20 | // 测试正常修改
21 | err := UpdateUserPassword(db, auth.ID, "654321")
22 | assert.Nil(t, err)
23 |
24 | // 测试不存在的用户
25 | err = UpdateUserPassword(db, auth.ID, "654321")
26 | assert.Nil(t, err)
27 | }
28 |
29 | func TestGetUserAuthInfoById(t *testing.T) {
30 | db := initDB()
31 | db.AutoMigrate(UserAuth{}, UserInfo{})
32 |
33 | var userAuth = UserAuth{
34 | Username: "test",
35 | Password: "123456",
36 | }
37 | db.Create(&userAuth)
38 |
39 | {
40 | val, err := GetUserAuthInfoById(db, userAuth.ID)
41 | assert.Nil(t, err)
42 | assert.Equal(t, "test", val.Username)
43 | }
44 |
45 | // 测试不存在的用户
46 | {
47 | val, err := GetUserAuthInfoById(db, -99)
48 | assert.Nil(t, val)
49 | assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
50 | }
51 | }
52 |
53 | func TestUpdateUserInfo(t *testing.T) {
54 | db := initDB()
55 | db.AutoMigrate(UserAuth{}, UserInfo{})
56 |
57 | userInfo := UserInfo{
58 | Nickname: "nickname",
59 | Avatar: "avatar",
60 | Intro: "intro",
61 | }
62 | db.Create(&userInfo)
63 |
64 | // 测试正常修改
65 | err := UpdateUserInfo(db, userInfo.ID, "update_nickname", "update_avatar", "intro", "website")
66 | assert.Nil(t, err)
67 |
68 | db.First(&userInfo, userInfo.ID)
69 | assert.Equal(t, "update_nickname", userInfo.Nickname)
70 | assert.Equal(t, "update_avatar", userInfo.Avatar)
71 | assert.Equal(t, "intro", userInfo.Intro)
72 | }
73 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/model/z_base_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/glebarez/sqlite"
8 | "github.com/stretchr/testify/assert"
9 | "gorm.io/gorm"
10 | )
11 |
12 | type user struct {
13 | UUID uint `gorm:"primarykey"`
14 | CreatedAt time.Time
15 | UpdatedAt time.Time
16 | Name string
17 | Email string
18 | Age int
19 | Enabled bool
20 | }
21 |
22 | type product struct {
23 | UUID string `gorm:"primarykey"`
24 | CreatedAt time.Time
25 | UpdatedAt time.Time
26 | Name string
27 | CanBuy bool
28 | }
29 |
30 | func initDB() *gorm.DB {
31 | db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{
32 | SkipDefaultTransaction: true,
33 | })
34 |
35 | db.AutoMigrate(user{}, product{})
36 | return db
37 | }
38 |
39 | func TestCreate(t *testing.T) {
40 | db := initDB()
41 |
42 | val, err := Create(db, &user{
43 | Name: "mockname",
44 | Age: 11,
45 | Enabled: true,
46 | })
47 | assert.Nil(t, err)
48 | assert.NotEmpty(t, val.UUID)
49 |
50 | p, err := Create(db, &product{
51 | UUID: "aaaa",
52 | Name: "demoproduct",
53 | CanBuy: true,
54 | })
55 | assert.Nil(t, err)
56 | assert.NotNil(t, p)
57 | }
58 |
59 | func TestCount(t *testing.T) {
60 | db := initDB()
61 |
62 | db.Create(&user{Name: "user1", Email: "user1@example.com", Age: 10})
63 | count, err := Count(db, &user{})
64 | assert.Nil(t, err)
65 | assert.Equal(t, 1, count)
66 |
67 | db.Create(&user{Name: "user2", Email: "user2@example.com", Age: 20})
68 | count, err = Count(db, &user{})
69 | assert.Nil(t, err)
70 | assert.Equal(t, 2, count)
71 |
72 | db.Create(&user{Name: "user3", Email: "user3@example.com", Age: 30})
73 | count, err = Count(db, &user{})
74 | assert.Nil(t, err)
75 | assert.Equal(t, 3, count)
76 |
77 | count, err = Count(db, &user{}, "age >= ?", 20)
78 | assert.Nil(t, err)
79 | assert.Equal(t, 2, count)
80 | }
81 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/utils/encrypt.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "crypto/md5"
5 | "encoding/hex"
6 |
7 | "golang.org/x/crypto/bcrypt"
8 | )
9 |
10 | // 使用 bcrypt 对字符串进行加密生成一个哈希值
11 | func BcryptHash(str string) (string, error) {
12 | bytes, err := bcrypt.GenerateFromPassword([]byte(str), bcrypt.DefaultCost)
13 | return string(bytes), err
14 | }
15 |
16 | // 使用 bcrypt 对比 明文字符串 和 哈希值
17 | func BcryptCheck(plain, hash string) bool {
18 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
19 | return err == nil
20 | }
21 |
22 | func MD5(str string, b ...byte) string {
23 | h := md5.New()
24 | h.Write([]byte(str))
25 | return hex.EncodeToString(h.Sum(b))
26 | }
27 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/utils/encrypt_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestEncrypt(t *testing.T) {
10 | password := "123456"
11 |
12 | // 加密
13 | hashPassword, err := BcryptHash(password)
14 | assert.Nil(t, err)
15 |
16 | // 验证
17 | result := BcryptCheck(password, hashPassword)
18 | assert.True(t, result)
19 | }
20 |
21 | func TestMD5(t *testing.T) {
22 | assert.Equal(t, "e10adc3949ba59abbe56e057f20f883e", MD5("123456"))
23 | }
24 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/utils/jwt/jwt.go:
--------------------------------------------------------------------------------
1 | package jwt
2 |
3 | import (
4 | "errors"
5 | "time"
6 |
7 | "github.com/golang-jwt/jwt/v4"
8 | )
9 |
10 | var (
11 | ErrTokenExpired = errors.New("token 已过期, 请重新登录")
12 | ErrTokenNotValidYet = errors.New("token 无效, 请重新登录")
13 | ErrTokenMalformed = errors.New("token 不正确, 请重新登录")
14 | ErrTokenInvalid = errors.New("这不是一个 token, 请重新登录")
15 | )
16 |
17 | type MyClaims struct {
18 | UserId int `json:"user_id"`
19 | RoleIds []int `json:"role_ids"`
20 | // UUID string `json:"uuid"`
21 | jwt.RegisteredClaims
22 | }
23 |
24 | func GenToken(secret, issuer string, expireHour, userId int, roleIds []int) (string, error) {
25 | claims := MyClaims{
26 | UserId: userId,
27 | RoleIds: roleIds,
28 | // UUID: uuid,
29 | RegisteredClaims: jwt.RegisteredClaims{
30 | Issuer: issuer,
31 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHour) * time.Hour)),
32 | IssuedAt: jwt.NewNumericDate(time.Now()),
33 | },
34 | }
35 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
36 | return token.SignedString([]byte(secret))
37 | }
38 |
39 | func ParseToken(secret, token string) (*MyClaims, error) {
40 | jwtToken, err := jwt.ParseWithClaims(token, &MyClaims{},
41 | func(token *jwt.Token) (interface{}, error) {
42 | return []byte(secret), nil
43 | })
44 |
45 | if err != nil {
46 | switch vError, ok := err.(*jwt.ValidationError); ok {
47 | case vError.Errors&jwt.ValidationErrorMalformed != 0:
48 | return nil, ErrTokenMalformed
49 | case vError.Errors&jwt.ValidationErrorExpired != 0:
50 | return nil, ErrTokenExpired
51 | case vError.Errors&jwt.ValidationErrorNotValidYet != 0:
52 | return nil, ErrTokenNotValidYet
53 | default:
54 | return nil, ErrTokenInvalid
55 | }
56 | }
57 |
58 | if claims, ok := jwtToken.Claims.(*MyClaims); ok && jwtToken.Valid {
59 | return claims, nil
60 | }
61 |
62 | return nil, ErrTokenInvalid
63 | }
64 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/utils/jwt/jwt_test.go:
--------------------------------------------------------------------------------
1 | package jwt
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestGenAndParseToken(t *testing.T) {
10 | secret := "secret"
11 | issuer := "issuer"
12 | expire := 10
13 |
14 | token, err := GenToken(secret, issuer, expire, 1, []int{1, 2})
15 | assert.Nil(t, err)
16 | assert.NotEmpty(t, token)
17 |
18 | mc, err := ParseToken(secret, token)
19 | assert.Nil(t, err)
20 | assert.Equal(t, 1, mc.UserId)
21 | assert.Len(t, mc.RoleIds, 2)
22 | }
23 |
24 | func TestParseTokenError(t *testing.T) {
25 | tokenString := "tokenString"
26 |
27 | _, err := ParseToken("secret", tokenString)
28 | assert.ErrorIs(t, err, ErrTokenMalformed)
29 | }
30 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/utils/upload/local.go:
--------------------------------------------------------------------------------
1 | package upload
2 |
3 | import (
4 | "errors"
5 | g "gin-blog/internal/global"
6 | "gin-blog/internal/utils"
7 | "io"
8 | "log/slog"
9 | "mime/multipart"
10 | "os"
11 | "path"
12 | "strings"
13 | "time"
14 | )
15 |
16 | // 本地文件上传
17 | type Local struct{}
18 |
19 | // 文件上传到本地
20 | func (*Local) UploadFile(file *multipart.FileHeader) (filePath, fileName string, err error) {
21 | ext := path.Ext(file.Filename) // 读取文件后缀
22 | name := strings.TrimSuffix(file.Filename, ext) // 读取文件名
23 | name = utils.MD5(name) // 加密文件名
24 | filename := name + "_" + time.Now().Format("20060102150405") + ext // 拼接新文件名
25 |
26 | conf := g.Conf.Upload
27 | mkdirErr := os.MkdirAll(conf.StorePath, os.ModePerm) // 尝试创建存储路径
28 | if mkdirErr != nil {
29 | slog.Error("function os.MkdirAll() Filed", slog.Any("err", mkdirErr.Error()))
30 | return "", "", errors.New("function os.MkdirAll() Filed, err:" + mkdirErr.Error())
31 | }
32 |
33 | storePath := conf.StorePath + "/" + filename // 文件存储路径
34 | filepath := conf.Path + "/" + filename // 文件展示路径
35 |
36 | f, openError := file.Open() // 读取文件
37 | if openError != nil {
38 | slog.Error("function file.Open() Filed", slog.String("err", openError.Error()))
39 | return "", "", errors.New("function file.Open() Filed, err:" + openError.Error())
40 | }
41 | defer f.Close() // 创建文件 defer 关闭
42 |
43 | out, createErr := os.Create(storePath)
44 | if createErr != nil {
45 | slog.Error("function os.Create() Filed", slog.String("err", createErr.Error()))
46 | return "", "", errors.New("function os.Create() Filed, err:" + createErr.Error())
47 | }
48 | defer out.Close() // 创建文件 defer 关闭
49 |
50 | _, copyErr := io.Copy(out, f) // 拷贝文件
51 | if copyErr != nil {
52 | slog.Error("function io.Copy() Filed", slog.String("err", copyErr.Error()))
53 | return "", "", errors.New("function io.Copy() Filed, err:" + copyErr.Error())
54 | }
55 | return filepath, filename, nil
56 | }
57 |
58 | // 从本地删除文件
59 | func (*Local) DeleteFile(key string) error {
60 | p := g.GetConfig().Upload.StorePath + "/" + key
61 | if strings.Contains(p, g.GetConfig().Upload.StorePath) {
62 | if err := os.Remove(p); err != nil {
63 | return errors.New("本地文件删除失败, err:" + err.Error())
64 | }
65 | }
66 | return nil
67 | }
68 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/utils/upload/oss.go:
--------------------------------------------------------------------------------
1 | package upload
2 |
3 | import (
4 | g "gin-blog/internal/global"
5 | "mime/multipart"
6 | )
7 |
8 | // OSS 对象存储接口
9 | type OSS interface {
10 | UploadFile(file *multipart.FileHeader) (string, string, error)
11 | DeleteFile(key string) error
12 | }
13 |
14 | // 根据配置文件中的配置判断文件上传实例
15 | func NewOSS() OSS {
16 | switch g.GetConfig().Upload.OssType {
17 | case "local":
18 | return &Local{}
19 | case "qiniu":
20 | return &Qiniu{}
21 | default:
22 | return &Local{}
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/utils/upload/qiniu.go:
--------------------------------------------------------------------------------
1 | package upload
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | g "gin-blog/internal/global"
8 | "gin-blog/internal/utils"
9 | "mime/multipart"
10 | "path"
11 | "time"
12 |
13 | "github.com/qiniu/go-sdk/v7/auth/qbox"
14 | "github.com/qiniu/go-sdk/v7/storage"
15 | )
16 |
17 | // 七牛云文件上传
18 | type Qiniu struct{}
19 |
20 | func (*Qiniu) UploadFile(file *multipart.FileHeader) (filePath, fileName string, err error) {
21 | putPolicy := storage.PutPolicy{Scope: g.GetConfig().Qiniu.Bucket}
22 | mac := qbox.NewMac(g.GetConfig().Qiniu.AccessKey, g.GetConfig().Qiniu.SecretKey)
23 | upToken := putPolicy.UploadToken(mac)
24 | formUploader := storage.NewFormUploader(qiniuConfig())
25 | ret := storage.PutRet{}
26 | putExtra := storage.PutExtra{Params: map[string]string{"x:name": "github logo"}}
27 |
28 | f, openError := file.Open()
29 | if openError != nil {
30 | return "", "", errors.New("function file.Open() Filed, err:" + openError.Error())
31 | }
32 | defer f.Close()
33 |
34 | // 文件名格式 建议保证唯一性
35 | fileKey := fmt.Sprintf("%d%s%s", time.Now().Unix(), utils.MD5(file.Filename), path.Ext(file.Filename))
36 | putErr := formUploader.Put(context.Background(), &ret, upToken, fileKey, f, file.Size, &putExtra)
37 | if putErr != nil {
38 | return "", "", errors.New("function formUploader.Put() Filed, err:" + putErr.Error())
39 | }
40 | return g.GetConfig().Qiniu.ImgPath + "/" + ret.Key, ret.Key, nil
41 | }
42 |
43 | func (*Qiniu) DeleteFile(key string) error {
44 | mac := qbox.NewMac(g.GetConfig().Qiniu.AccessKey, g.GetConfig().Qiniu.SecretKey)
45 | cfg := qiniuConfig()
46 | bucketManager := storage.NewBucketManager(mac, cfg)
47 | if err := bucketManager.Delete(g.GetConfig().Qiniu.Bucket, key); err != nil {
48 | return errors.New("function bucketManager.Delete() Filed, err:" + err.Error())
49 | }
50 | return nil
51 | }
52 |
53 | // 七牛云配置信息
54 | func qiniuConfig() *storage.Config {
55 | cfg := storage.Config{
56 | UseHTTPS: g.GetConfig().Qiniu.UseHTTPS,
57 | UseCdnDomains: g.GetConfig().Qiniu.UseCdnDomains,
58 | }
59 | switch g.GetConfig().Qiniu.Zone { // 根据配置文件进行初始化空间对应的机房
60 | case "ZoneHuadong":
61 | cfg.Zone = &storage.ZoneHuadong
62 | case "ZoneHuabei":
63 | cfg.Zone = &storage.ZoneHuabei
64 | case "ZoneHuanan":
65 | cfg.Zone = &storage.ZoneHuanan
66 | case "ZoneBeimei":
67 | cfg.Zone = &storage.ZoneBeimei
68 | case "ZoneXinjiapo":
69 | cfg.Zone = &storage.ZoneXinjiapo
70 | }
71 | return &cfg
72 | }
73 |
--------------------------------------------------------------------------------
/gin-blog-server/internal/utils/upload/tencent.go:
--------------------------------------------------------------------------------
1 | package upload
2 |
--------------------------------------------------------------------------------
/gin-blog-server/swag_init.sh:
--------------------------------------------------------------------------------
1 | swag init -g ./cmd/main.go
--------------------------------------------------------------------------------
/images/前台文章列表.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szluyu99/gin-vue-blog/61dd11ccd296e8642a318ada3ef7b3f7776d2410/images/前台文章列表.png
--------------------------------------------------------------------------------
/images/前台首页.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szluyu99/gin-vue-blog/61dd11ccd296e8642a318ada3ef7b3f7776d2410/images/前台首页.png
--------------------------------------------------------------------------------
/images/后台文章列表.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szluyu99/gin-vue-blog/61dd11ccd296e8642a318ada3ef7b3f7776d2410/images/后台文章列表.png
--------------------------------------------------------------------------------
/images/头像.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/szluyu99/gin-vue-blog/61dd11ccd296e8642a318ada3ef7b3f7776d2410/images/头像.jpeg
--------------------------------------------------------------------------------