├── .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 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
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 | 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 | 76 | -------------------------------------------------------------------------------- /gin-blog-admin/src/components/common/AppPage.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 19 | 20 | 76 | -------------------------------------------------------------------------------- /gin-blog-admin/src/components/common/CommonPage.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 33 | -------------------------------------------------------------------------------- /gin-blog-admin/src/components/common/TheFooter.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 14 | -------------------------------------------------------------------------------- /gin-blog-admin/src/components/crud/CrudModal.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 47 | -------------------------------------------------------------------------------- /gin-blog-admin/src/components/crud/QueryItem.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | -------------------------------------------------------------------------------- /gin-blog-admin/src/components/icon/IconPicker.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 69 | -------------------------------------------------------------------------------- /gin-blog-admin/src/components/icon/TheIcon.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 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 | 35 | -------------------------------------------------------------------------------- /gin-blog-admin/src/layout/header/components/FullScreen.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 14 | -------------------------------------------------------------------------------- /gin-blog-admin/src/layout/header/components/GithubSite.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /gin-blog-admin/src/layout/header/components/MenuCollapse.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 14 | -------------------------------------------------------------------------------- /gin-blog-admin/src/layout/header/components/ThemeMode.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 14 | -------------------------------------------------------------------------------- /gin-blog-admin/src/layout/header/components/UserAvatar.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 50 | -------------------------------------------------------------------------------- /gin-blog-admin/src/layout/header/components/Watermark.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 29 | -------------------------------------------------------------------------------- /gin-blog-admin/src/layout/header/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 31 | -------------------------------------------------------------------------------- /gin-blog-admin/src/layout/index.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 66 | -------------------------------------------------------------------------------- /gin-blog-admin/src/layout/sidebar/components/SideLogo.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | -------------------------------------------------------------------------------- /gin-blog-admin/src/layout/sidebar/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 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 | 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 | 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 | 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 | 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 | 38 | -------------------------------------------------------------------------------- /gin-blog-front/src/components/BannerPage.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 85 | -------------------------------------------------------------------------------- /gin-blog-front/src/components/comment/Paging.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 68 | 69 | 75 | -------------------------------------------------------------------------------- /gin-blog-front/src/components/layout/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | 38 | -------------------------------------------------------------------------------- /gin-blog-front/src/components/modal/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /gin-blog-front/src/components/ui/UButton.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 23 | 24 | 47 | -------------------------------------------------------------------------------- /gin-blog-front/src/components/ui/UDrawer.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 88 | -------------------------------------------------------------------------------- /gin-blog-front/src/components/ui/ULoading.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 48 | -------------------------------------------------------------------------------- /gin-blog-front/src/components/ui/USpin.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 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 | 47 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/article/detail/components/Catalogue.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 77 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/article/detail/components/Copyright.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 39 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/article/detail/components/Forward.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 32 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/article/detail/components/LastNext.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 33 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/article/detail/components/LatestList.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 35 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/article/detail/components/Recommend.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 32 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/article/detail/components/Reward.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 62 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/discover/category/index.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 40 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/discover/tag/index.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 45 | 46 | 52 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/entertainment/album/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/entertainment/talking/index.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/error-page/404.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/home/components/Announcement.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/home/components/TalkingCarousel.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 24 | 25 | 37 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/home/components/WebsiteInfo.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 50 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/link/components/AddLink.vue: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/link/components/LinkList.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 53 | 54 | 79 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/link/index.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 34 | 35 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/user/UploadOne.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 76 | -------------------------------------------------------------------------------- /gin-blog-front/src/views/user/index.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 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 | 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 | 30 | 31 |
8 |

👋  你好~ {{.UserName}} ~

9 |

💡  感谢您注册账户,很高兴您可以加入我们的大家庭!请激活您的账户。

10 |

📬  这只需简单一步:点击以下按钮验证您的电子邮件地址。

11 | 12 | 13 | 14 | 23 | 24 | 25 | 26 |

🕹  激活后,您将完成访问!

27 |

💃  按钮没反应?尝试将此 URL 粘贴到您的浏览器中:{{.URL}}

28 |

😉  我们期待着您的到来!

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